packc/
build.rs

1use crate::cli::resolve::{self, ResolveArgs};
2use crate::config::{
3    AssetConfig, ComponentConfig, ComponentOperationConfig, FlowConfig, PackConfig,
4};
5use crate::extensions::validate_components_extension;
6use crate::flow_resolve::enforce_sidecar_mappings;
7use crate::runtime::RuntimeContext;
8use anyhow::{Context, Result, anyhow};
9use greentic_flow::add_step::normalize::normalize_node_map;
10use greentic_flow::compile_ygtc_file;
11use greentic_flow::loader::load_ygtc_from_path;
12use greentic_pack::builder::SbomEntry;
13use greentic_pack::pack_lock::read_pack_lock;
14use greentic_types::component_source::ComponentSourceRef;
15use greentic_types::pack::extensions::component_manifests::{
16    ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
17    ManifestEncoding,
18};
19use greentic_types::pack::extensions::component_sources::{
20    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
21    ResolvedComponentV1,
22};
23use greentic_types::{
24    BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
25    ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
26    PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
27    encode_pack_manifest,
28};
29use semver::Version;
30use serde::Serialize;
31use serde_cbor;
32use serde_yaml_bw::Value as YamlValue;
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeMap, BTreeSet};
35use std::fs;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::str::FromStr;
39use tracing::info;
40use zip::write::SimpleFileOptions;
41use zip::{CompressionMethod, ZipWriter};
42
43const SBOM_FORMAT: &str = "greentic-sbom-v1";
44
45#[derive(Serialize)]
46struct SbomDocument {
47    format: String,
48    files: Vec<SbomEntry>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
52pub enum BundleMode {
53    Cache,
54    None,
55}
56
57#[derive(Clone)]
58pub struct BuildOptions {
59    pub pack_dir: PathBuf,
60    pub component_out: Option<PathBuf>,
61    pub manifest_out: PathBuf,
62    pub sbom_out: Option<PathBuf>,
63    pub gtpack_out: Option<PathBuf>,
64    pub lock_path: PathBuf,
65    pub bundle: BundleMode,
66    pub dry_run: bool,
67    pub secrets_req: Option<PathBuf>,
68    pub default_secret_scope: Option<String>,
69    pub allow_oci_tags: bool,
70    pub runtime: RuntimeContext,
71    pub skip_update: bool,
72}
73
74impl BuildOptions {
75    pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
76        let pack_dir = args
77            .input
78            .canonicalize()
79            .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
80
81        let component_out = args
82            .component_out
83            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
84        let manifest_out = args
85            .manifest
86            .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
87            .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
88        let sbom_out = args
89            .sbom
90            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
91        let default_gtpack_name = pack_dir
92            .file_name()
93            .and_then(|name| name.to_str())
94            .unwrap_or("pack");
95        let default_gtpack_out = pack_dir
96            .join("dist")
97            .join(format!("{default_gtpack_name}.gtpack"));
98        let gtpack_out = Some(
99            args.gtpack_out
100                .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
101                .unwrap_or(default_gtpack_out),
102        );
103        let lock_path = args
104            .lock
105            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
106            .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
107
108        Ok(Self {
109            pack_dir,
110            component_out,
111            manifest_out,
112            sbom_out,
113            gtpack_out,
114            lock_path,
115            bundle: args.bundle,
116            dry_run: args.dry_run,
117            secrets_req: args.secrets_req,
118            default_secret_scope: args.default_secret_scope,
119            allow_oci_tags: args.allow_oci_tags,
120            runtime: runtime.clone(),
121            skip_update: args.no_update,
122        })
123    }
124}
125
126pub async fn run(opts: &BuildOptions) -> Result<()> {
127    info!(
128        pack_dir = %opts.pack_dir.display(),
129        manifest_out = %opts.manifest_out.display(),
130        gtpack_out = ?opts.gtpack_out,
131        dry_run = opts.dry_run,
132        "building greentic pack"
133    );
134
135    if !opts.skip_update {
136        // Keep pack.yaml in sync before building.
137        crate::cli::update::update_pack(&opts.pack_dir, false)?;
138    }
139
140    // Resolve component references into pack.lock.json before building to ensure
141    // manifests/extensions can rely on the lockfile contents.
142    resolve::handle(
143        ResolveArgs {
144            input: opts.pack_dir.clone(),
145            lock: Some(opts.lock_path.clone()),
146        },
147        &opts.runtime,
148        false,
149    )
150    .await?;
151
152    let config = crate::config::load_pack_config(&opts.pack_dir)?;
153    info!(
154        id = %config.pack_id,
155        version = %config.version,
156        kind = %config.kind,
157        components = config.components.len(),
158        flows = config.flows.len(),
159        dependencies = config.dependencies.len(),
160        "loaded pack.yaml"
161    );
162    validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
163
164    let secret_requirements = aggregate_secret_requirements(
165        &config.components,
166        opts.secrets_req.as_deref(),
167        opts.default_secret_scope.as_deref(),
168    )?;
169
170    if !opts.lock_path.exists() {
171        anyhow::bail!(
172            "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
173            opts.lock_path.display()
174        );
175    }
176    let pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
177        format!(
178            "failed to read pack lock {} (try `greentic-pack resolve`)",
179            opts.lock_path.display()
180        )
181    })?;
182
183    let mut build = assemble_manifest(&config, &opts.pack_dir, &secret_requirements)?;
184    build.manifest.extensions =
185        merge_component_sources_extension(build.manifest.extensions, &pack_lock, opts.bundle)?;
186
187    let manifest_bytes = encode_pack_manifest(&build.manifest)?;
188    info!(len = manifest_bytes.len(), "encoded manifest.cbor");
189
190    if opts.dry_run {
191        info!("dry-run complete; no files written");
192        return Ok(());
193    }
194
195    if let Some(component_out) = opts.component_out.as_ref() {
196        write_stub_wasm(component_out)?;
197    }
198
199    write_bytes(&opts.manifest_out, &manifest_bytes)?;
200
201    if let Some(sbom_out) = opts.sbom_out.as_ref() {
202        write_bytes(sbom_out, br#"{"files":[]} "#)?;
203    }
204
205    if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
206        let mut build = build;
207        if !secret_requirements.is_empty() {
208            let logical = "secret-requirements.json".to_string();
209            let req_path =
210                write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
211            build.assets.push(AssetFile {
212                logical_path: logical,
213                source: req_path,
214            });
215        }
216        package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle)?;
217        info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
218        eprintln!("wrote {}", gtpack_out.display());
219    }
220
221    Ok(())
222}
223
224struct BuildProducts {
225    manifest: PackManifest,
226    components: Vec<ComponentBinary>,
227    assets: Vec<AssetFile>,
228}
229
230#[derive(Clone)]
231struct ComponentBinary {
232    id: String,
233    source: PathBuf,
234    manifest_bytes: Vec<u8>,
235    manifest_path: String,
236    manifest_hash_sha256: String,
237}
238
239struct AssetFile {
240    logical_path: String,
241    source: PathBuf,
242}
243
244fn assemble_manifest(
245    config: &PackConfig,
246    pack_root: &Path,
247    secret_requirements: &[SecretRequirement],
248) -> Result<BuildProducts> {
249    let components = build_components(&config.components)?;
250    let component_ids: BTreeSet<String> = config.components.iter().map(|c| c.id.clone()).collect();
251    let flows = build_flows(&config.flows, &component_ids, pack_root)?;
252    let dependencies = build_dependencies(&config.dependencies)?;
253    let assets = collect_assets(&config.assets, pack_root)?;
254    let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
255    let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
256    let extensions =
257        merge_component_manifest_extension(normalize_extensions(&config.extensions), &components)?;
258
259    let manifest = PackManifest {
260        schema_version: "pack-v1".to_string(),
261        pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
262        version: Version::parse(&config.version)
263            .context("invalid pack version (expected semver)")?,
264        kind: map_kind(&config.kind)?,
265        publisher: config.publisher.clone(),
266        components: component_manifests,
267        flows,
268        dependencies,
269        capabilities: derive_pack_capabilities(&components),
270        secret_requirements: secret_requirements.to_vec(),
271        signatures: PackSignatures::default(),
272        bootstrap,
273        extensions,
274    };
275
276    Ok(BuildProducts {
277        manifest,
278        components: components.into_iter().map(|(_, bin)| bin).collect(),
279        assets,
280    })
281}
282
283fn build_components(
284    configs: &[ComponentConfig],
285) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
286    let mut seen = BTreeSet::new();
287    let mut result = Vec::new();
288
289    for cfg in configs {
290        if !seen.insert(cfg.id.clone()) {
291            anyhow::bail!("duplicate component id {}", cfg.id);
292        }
293
294        info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
295        let (manifest, binary) = resolve_component_artifacts(cfg)?;
296
297        result.push((manifest, binary));
298    }
299
300    Ok(result)
301}
302
303fn resolve_component_artifacts(
304    cfg: &ComponentConfig,
305) -> Result<(ComponentManifest, ComponentBinary)> {
306    let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
307
308    let mut manifest = if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm)? {
309        if from_disk.id.to_string() != cfg.id {
310            anyhow::bail!(
311                "component manifest id {} does not match pack.yaml id {}",
312                from_disk.id,
313                cfg.id
314            );
315        }
316        if from_disk.version.to_string() != cfg.version {
317            anyhow::bail!(
318                "component manifest version {} does not match pack.yaml version {}",
319                from_disk.version,
320                cfg.version
321            );
322        }
323        from_disk
324    } else {
325        manifest_from_config(cfg)?
326    };
327
328    // Ensure operations are populated from pack.yaml when missing in the on-disk manifest.
329    if manifest.operations.is_empty() && !cfg.operations.is_empty() {
330        manifest.operations = cfg
331            .operations
332            .iter()
333            .map(operation_from_config)
334            .collect::<Result<Vec<_>>>()?;
335    }
336
337    let manifest_bytes =
338        serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
339    let mut sha = Sha256::new();
340    sha.update(&manifest_bytes);
341    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
342    let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
343
344    let binary = ComponentBinary {
345        id: cfg.id.clone(),
346        source: resolved_wasm,
347        manifest_bytes,
348        manifest_path,
349        manifest_hash_sha256,
350    };
351
352    Ok((manifest, binary))
353}
354
355fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
356    Ok(ComponentManifest {
357        id: ComponentId::new(cfg.id.clone()).context("invalid component id")?,
358        version: Version::parse(&cfg.version)
359            .context("invalid component version (expected semver)")?,
360        supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
361        world: cfg.world.clone(),
362        profiles: cfg.profiles.clone(),
363        capabilities: cfg.capabilities.clone(),
364        configurators: convert_configurators(cfg)?,
365        operations: cfg
366            .operations
367            .iter()
368            .map(operation_from_config)
369            .collect::<Result<Vec<_>>>()?,
370        config_schema: cfg.config_schema.clone(),
371        resources: cfg.resources.clone().unwrap_or_default(),
372        dev_flows: BTreeMap::new(),
373    })
374}
375
376fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
377    if path.is_file() {
378        return Ok(path.to_path_buf());
379    }
380    if !path.exists() {
381        anyhow::bail!("component path {} does not exist", path.display());
382    }
383    if !path.is_dir() {
384        anyhow::bail!(
385            "component path {} must be a file or directory",
386            path.display()
387        );
388    }
389
390    let mut component_candidates = Vec::new();
391    let mut wasm_candidates = Vec::new();
392    let mut stack = vec![path.to_path_buf()];
393    while let Some(current) = stack.pop() {
394        for entry in fs::read_dir(&current)
395            .with_context(|| format!("failed to list components in {}", current.display()))?
396        {
397            let entry = entry?;
398            let entry_type = entry.file_type()?;
399            let entry_path = entry.path();
400            if entry_type.is_dir() {
401                stack.push(entry_path);
402                continue;
403            }
404            if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
405            {
406                let file_name = entry_path
407                    .file_name()
408                    .and_then(|n| n.to_str())
409                    .unwrap_or_default();
410                if file_name.ends_with(".component.wasm") {
411                    component_candidates.push(entry_path);
412                } else {
413                    wasm_candidates.push(entry_path);
414                }
415            }
416        }
417    }
418
419    let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
420        list.sort();
421        if list.len() == 1 {
422            Ok(list.remove(0))
423        } else {
424            let options = list
425                .iter()
426                .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
427                .collect::<Vec<_>>()
428                .join(", ");
429            anyhow::bail!(
430                "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
431                path.display(),
432                options
433            );
434        }
435    };
436
437    if !component_candidates.is_empty() {
438        return choose(component_candidates);
439    }
440    if !wasm_candidates.is_empty() {
441        return choose(wasm_candidates);
442    }
443
444    anyhow::bail!(
445        "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
446        path.display()
447    );
448}
449
450fn load_component_manifest_from_disk(path: &Path) -> Result<Option<ComponentManifest>> {
451    let manifest_dir = if path.is_dir() {
452        path.to_path_buf()
453    } else {
454        path.parent()
455            .map(Path::to_path_buf)
456            .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
457    };
458    let manifest_path = manifest_dir.join("component.json");
459    if !manifest_path.exists() {
460        return Ok(None);
461    }
462
463    let manifest: ComponentManifest = serde_json::from_slice(
464        &fs::read(&manifest_path)
465            .with_context(|| format!("failed to read {}", manifest_path.display()))?,
466    )
467    .with_context(|| format!("{} is not a valid component.json", manifest_path.display()))?;
468
469    Ok(Some(manifest))
470}
471
472fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
473    Ok(ComponentOperation {
474        name: cfg.name.clone(),
475        input_schema: cfg.input_schema.clone(),
476        output_schema: cfg.output_schema.clone(),
477    })
478}
479
480fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
481    let Some(configurators) = cfg.configurators.as_ref() else {
482        return Ok(None);
483    };
484
485    let basic = match &configurators.basic {
486        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
487        None => None,
488    };
489    let full = match &configurators.full {
490        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
491        None => None,
492    };
493
494    Ok(Some(ComponentConfigurators { basic, full }))
495}
496
497fn build_bootstrap(
498    config: &PackConfig,
499    flows: &[PackFlowEntry],
500    components: &[ComponentManifest],
501) -> Result<Option<BootstrapSpec>> {
502    let Some(raw) = config.bootstrap.as_ref() else {
503        return Ok(None);
504    };
505
506    let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
507    let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
508
509    let mut spec = BootstrapSpec::default();
510
511    if let Some(install_flow) = &raw.install_flow {
512        if !flow_ids.contains(install_flow) {
513            anyhow::bail!(
514                "bootstrap.install_flow references unknown flow {}",
515                install_flow
516            );
517        }
518        spec.install_flow = Some(install_flow.clone());
519    }
520
521    if let Some(upgrade_flow) = &raw.upgrade_flow {
522        if !flow_ids.contains(upgrade_flow) {
523            anyhow::bail!(
524                "bootstrap.upgrade_flow references unknown flow {}",
525                upgrade_flow
526            );
527        }
528        spec.upgrade_flow = Some(upgrade_flow.clone());
529    }
530
531    if let Some(component) = &raw.installer_component {
532        if !component_ids.contains(component) {
533            anyhow::bail!(
534                "bootstrap.installer_component references unknown component {}",
535                component
536            );
537        }
538        spec.installer_component = Some(component.clone());
539    }
540
541    if spec.install_flow.is_none()
542        && spec.upgrade_flow.is_none()
543        && spec.installer_component.is_none()
544    {
545        return Ok(None);
546    }
547
548    Ok(Some(spec))
549}
550
551fn build_flows(
552    configs: &[FlowConfig],
553    component_ids: &BTreeSet<String>,
554    pack_root: &Path,
555) -> Result<Vec<PackFlowEntry>> {
556    let mut seen = BTreeSet::new();
557    let mut entries = Vec::new();
558
559    for cfg in configs {
560        info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
561        let mut flow: Flow = compile_ygtc_file(&cfg.file)
562            .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
563        populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
564            format!(
565                "failed to resolve component.exec operations in {}",
566                cfg.file.display()
567            )
568        })?;
569        resolve_missing_component_ids(&mut flow, component_ids).with_context(|| {
570            format!("failed to resolve component ids in {}", cfg.file.display())
571        })?;
572        enforce_sidecar_mappings(pack_root, cfg, &flow)?;
573
574        let flow_id = flow.id.to_string();
575        if !seen.insert(flow_id.clone()) {
576            anyhow::bail!("duplicate flow id {}", flow_id);
577        }
578
579        let entrypoints = if cfg.entrypoints.is_empty() {
580            flow.entrypoints.keys().cloned().collect()
581        } else {
582            cfg.entrypoints.clone()
583        };
584
585        let flow_entry = PackFlowEntry {
586            id: flow.id.clone(),
587            kind: flow.kind,
588            flow,
589            tags: cfg.tags.clone(),
590            entrypoints,
591        };
592        entries.push(flow_entry);
593    }
594
595    Ok(entries)
596}
597
598fn infer_component_id_for_node(node_id: &str, components: &BTreeSet<String>) -> Result<String> {
599    if components.is_empty() {
600        anyhow::bail!(
601            "node {} is missing component id and no packaged components are available to resolve it",
602            node_id
603        );
604    }
605
606    if components.contains(node_id) {
607        return Ok(node_id.to_string());
608    }
609
610    let suffix_matches: Vec<_> = components
611        .iter()
612        .filter(|candidate| candidate.rsplit('.').next() == Some(node_id))
613        .collect();
614    if suffix_matches.len() == 1 {
615        return Ok((*suffix_matches[0]).clone());
616    }
617
618    if components.len() == 1 {
619        return Ok(components
620            .iter()
621            .next()
622            .expect("component set is non-empty")
623            .clone());
624    }
625
626    anyhow::bail!(
627        "node {} is missing component id and could not be matched to packaged components: {}",
628        node_id,
629        components.iter().cloned().collect::<Vec<_>>().join(", ")
630    );
631}
632
633fn resolve_missing_component_ids(flow: &mut Flow, components: &BTreeSet<String>) -> Result<()> {
634    for (node_id, node) in flow.nodes.iter_mut() {
635        if !node.component.id.as_str().is_empty() && node.component.id.as_str() != "component.exec"
636        {
637            continue;
638        }
639
640        let component_id = infer_component_id_for_node(node_id.as_str(), components)?;
641
642        node.component.id = ComponentId::new(&component_id)
643            .with_context(|| format!("invalid component id resolved for node {}", node_id))?;
644    }
645    Ok(())
646}
647
648fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
649    let needs_op = flow.nodes.values().any(|node| {
650        node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
651    });
652    if !needs_op {
653        return Ok(());
654    }
655
656    let flow_doc = load_ygtc_from_path(path)?;
657    let mut operations = BTreeMap::new();
658
659    for (node_id, node_doc) in flow_doc.nodes {
660        let value = serde_json::to_value(&node_doc)
661            .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
662        let normalized = normalize_node_map(value)?;
663        if !normalized.operation.trim().is_empty() {
664            operations.insert(node_id, normalized.operation);
665        }
666    }
667
668    for (node_id, node) in flow.nodes.iter_mut() {
669        if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
670            continue;
671        }
672        if let Some(op) = operations.get(node_id.as_str()) {
673            node.component.operation = Some(op.clone());
674        }
675    }
676
677    Ok(())
678}
679
680fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
681    let mut deps = Vec::new();
682    let mut seen = BTreeSet::new();
683    for cfg in configs {
684        if !seen.insert(cfg.alias.clone()) {
685            anyhow::bail!("duplicate dependency alias {}", cfg.alias);
686        }
687        deps.push(PackDependency {
688            alias: cfg.alias.clone(),
689            pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
690            version_req: SemverReq::parse(&cfg.version_req)
691                .context("invalid dependency version requirement")?,
692            required_capabilities: cfg.required_capabilities.clone(),
693        });
694    }
695    Ok(deps)
696}
697
698fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
699    let mut assets = Vec::new();
700    for cfg in configs {
701        let logical = cfg
702            .path
703            .strip_prefix(pack_root)
704            .unwrap_or(&cfg.path)
705            .components()
706            .map(|c| c.as_os_str().to_string_lossy().into_owned())
707            .collect::<Vec<_>>()
708            .join("/");
709        if logical.is_empty() {
710            anyhow::bail!("invalid asset path {}", cfg.path.display());
711        }
712        assets.push(AssetFile {
713            logical_path: logical,
714            source: cfg.path.clone(),
715        });
716    }
717    Ok(assets)
718}
719
720fn normalize_extensions(
721    extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
722) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
723    extensions.as_ref().filter(|map| !map.is_empty()).cloned()
724}
725
726fn merge_component_manifest_extension(
727    extensions: Option<BTreeMap<String, ExtensionRef>>,
728    components: &[(ComponentManifest, ComponentBinary)],
729) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
730    let entries: Vec<_> = components
731        .iter()
732        .map(|(manifest, binary)| ComponentManifestIndexEntryV1 {
733            component_id: manifest.id.to_string(),
734            manifest_file: binary.manifest_path.clone(),
735            encoding: ManifestEncoding::Cbor,
736            content_hash: Some(binary.manifest_hash_sha256.clone()),
737        })
738        .collect();
739
740    let index = ComponentManifestIndexV1::new(entries);
741    let value = index
742        .to_extension_value()
743        .context("serialize component manifest index extension")?;
744
745    let ext = ExtensionRef {
746        kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
747        version: "v1".to_string(),
748        digest: None,
749        location: None,
750        inline: Some(ExtensionInline::Other(value)),
751    };
752
753    let mut map = extensions.unwrap_or_default();
754    map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
755    if map.is_empty() {
756        Ok(None)
757    } else {
758        Ok(Some(map))
759    }
760}
761
762fn merge_component_sources_extension(
763    extensions: Option<BTreeMap<String, ExtensionRef>>,
764    lock: &greentic_pack::pack_lock::PackLockV1,
765    bundle: BundleMode,
766) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
767    let mut entries = Vec::new();
768    for comp in &lock.components {
769        let source = match ComponentSourceRef::from_str(&comp.r#ref) {
770            Ok(parsed) => parsed,
771            Err(_) => {
772                eprintln!(
773                    "warning: skipping pack.lock entry `{}` with unsupported ref {}",
774                    comp.name, comp.r#ref
775                );
776                continue;
777            }
778        };
779        let artifact = match bundle {
780            BundleMode::None => ArtifactLocationV1::Remote,
781            BundleMode::Cache => ArtifactLocationV1::Inline {
782                wasm_path: format!("components/{}.wasm", comp.name),
783                manifest_path: None,
784            },
785        };
786        entries.push(ComponentSourceEntryV1 {
787            name: comp.name.clone(),
788            component_id: None,
789            source,
790            resolved: ResolvedComponentV1 {
791                digest: comp.digest.clone(),
792                signature: None,
793                signed_by: None,
794            },
795            artifact,
796            licensing_hint: None,
797            metering_hint: None,
798        });
799    }
800
801    if entries.is_empty() {
802        return Ok(extensions);
803    }
804
805    let payload = ComponentSourcesV1::new(entries)
806        .to_extension_value()
807        .context("serialize component_sources extension")?;
808
809    let ext = ExtensionRef {
810        kind: EXT_COMPONENT_SOURCES_V1.to_string(),
811        version: "v1".to_string(),
812        digest: None,
813        location: None,
814        inline: Some(ExtensionInline::Other(payload)),
815    };
816
817    let mut map = extensions.unwrap_or_default();
818    map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
819    if map.is_empty() {
820        Ok(None)
821    } else {
822        Ok(Some(map))
823    }
824}
825
826fn derive_pack_capabilities(
827    components: &[(ComponentManifest, ComponentBinary)],
828) -> Vec<ComponentCapability> {
829    let mut seen = BTreeSet::new();
830    let mut caps = Vec::new();
831
832    for (component, _) in components {
833        let mut add = |name: &str| {
834            if seen.insert(name.to_string()) {
835                caps.push(ComponentCapability {
836                    name: name.to_string(),
837                    description: None,
838                });
839            }
840        };
841
842        if component.capabilities.host.secrets.is_some() {
843            add("host:secrets");
844        }
845        if let Some(state) = &component.capabilities.host.state {
846            if state.read {
847                add("host:state:read");
848            }
849            if state.write {
850                add("host:state:write");
851            }
852        }
853        if component.capabilities.host.messaging.is_some() {
854            add("host:messaging");
855        }
856        if component.capabilities.host.events.is_some() {
857            add("host:events");
858        }
859        if component.capabilities.host.http.is_some() {
860            add("host:http");
861        }
862        if component.capabilities.host.telemetry.is_some() {
863            add("host:telemetry");
864        }
865        if component.capabilities.host.iac.is_some() {
866            add("host:iac");
867        }
868        if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
869            add(&format!(
870                "wasi:fs:{}",
871                format!("{:?}", fs.mode).to_lowercase()
872            ));
873            if !fs.mounts.is_empty() {
874                add("wasi:fs:mounts");
875            }
876        }
877        if component.capabilities.wasi.random {
878            add("wasi:random");
879        }
880        if component.capabilities.wasi.clocks {
881            add("wasi:clocks");
882        }
883    }
884
885    caps
886}
887
888fn map_kind(raw: &str) -> Result<PackKind> {
889    match raw.to_ascii_lowercase().as_str() {
890        "application" => Ok(PackKind::Application),
891        "provider" => Ok(PackKind::Provider),
892        "infrastructure" => Ok(PackKind::Infrastructure),
893        "library" => Ok(PackKind::Library),
894        other => Err(anyhow!("unknown pack kind {}", other)),
895    }
896}
897
898fn package_gtpack(
899    out_path: &Path,
900    manifest_bytes: &[u8],
901    build: &BuildProducts,
902    bundle: BundleMode,
903) -> Result<()> {
904    if let Some(parent) = out_path.parent() {
905        fs::create_dir_all(parent)
906            .with_context(|| format!("failed to create {}", parent.display()))?;
907    }
908
909    let file = fs::File::create(out_path)
910        .with_context(|| format!("failed to create {}", out_path.display()))?;
911    let mut writer = ZipWriter::new(file);
912    let options = SimpleFileOptions::default()
913        .compression_method(CompressionMethod::Stored)
914        .unix_permissions(0o644);
915
916    let mut sbom_entries = Vec::new();
917    record_sbom_entry(
918        &mut sbom_entries,
919        "manifest.cbor",
920        manifest_bytes,
921        "application/cbor",
922    );
923    write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
924
925    if bundle != BundleMode::None {
926        let mut components = build.components.clone();
927        components.sort_by(|a, b| a.id.cmp(&b.id));
928        for comp in components {
929            let logical_wasm = format!("components/{}.wasm", comp.id);
930            let wasm_bytes = fs::read(&comp.source)
931                .with_context(|| format!("failed to read component {}", comp.source.display()))?;
932            record_sbom_entry(
933                &mut sbom_entries,
934                &logical_wasm,
935                &wasm_bytes,
936                "application/wasm",
937            );
938            write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
939
940            record_sbom_entry(
941                &mut sbom_entries,
942                &comp.manifest_path,
943                &comp.manifest_bytes,
944                "application/cbor",
945            );
946            write_zip_entry(
947                &mut writer,
948                &comp.manifest_path,
949                &comp.manifest_bytes,
950                options,
951            )?;
952        }
953    }
954
955    let mut asset_entries: Vec<_> = build
956        .assets
957        .iter()
958        .map(|a| (format!("assets/{}", &a.logical_path), a.source.clone()))
959        .collect();
960    asset_entries.sort_by(|a, b| a.0.cmp(&b.0));
961    for (logical, source) in asset_entries {
962        let bytes = fs::read(&source)
963            .with_context(|| format!("failed to read asset {}", source.display()))?;
964        record_sbom_entry(
965            &mut sbom_entries,
966            &logical,
967            &bytes,
968            "application/octet-stream",
969        );
970        write_zip_entry(&mut writer, &logical, &bytes, options)?;
971    }
972
973    sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
974    let sbom_doc = SbomDocument {
975        format: SBOM_FORMAT.to_string(),
976        files: sbom_entries,
977    };
978    let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
979    write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
980
981    writer
982        .finish()
983        .context("failed to finalise gtpack archive")?;
984    Ok(())
985}
986
987fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
988    entries.push(SbomEntry {
989        path: path.to_string(),
990        size: bytes.len() as u64,
991        hash_blake3: blake3::hash(bytes).to_hex().to_string(),
992        media_type: media_type.to_string(),
993    });
994}
995
996fn write_zip_entry(
997    writer: &mut ZipWriter<std::fs::File>,
998    logical_path: &str,
999    bytes: &[u8],
1000    options: SimpleFileOptions,
1001) -> Result<()> {
1002    writer
1003        .start_file(logical_path, options)
1004        .with_context(|| format!("failed to start {}", logical_path))?;
1005    writer
1006        .write_all(bytes)
1007        .with_context(|| format!("failed to write {}", logical_path))?;
1008    Ok(())
1009}
1010
1011fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1012    if let Some(parent) = path.parent() {
1013        fs::create_dir_all(parent)
1014            .with_context(|| format!("failed to create directory {}", parent.display()))?;
1015    }
1016    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1017    Ok(())
1018}
1019
1020fn write_stub_wasm(path: &Path) -> Result<()> {
1021    const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1022    write_bytes(path, STUB)
1023}
1024
1025fn aggregate_secret_requirements(
1026    components: &[ComponentConfig],
1027    override_path: Option<&Path>,
1028    default_scope: Option<&str>,
1029) -> Result<Vec<SecretRequirement>> {
1030    let default_scope = default_scope.map(parse_default_scope).transpose()?;
1031    let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1032
1033    let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1034        let mut req = req.clone();
1035        if req.scope.is_none() {
1036            if let Some(scope) = default_scope.clone() {
1037                req.scope = Some(scope);
1038                tracing::warn!(
1039                    key = %secret_key_string(&req),
1040                    source,
1041                    "secret requirement missing scope; applying default scope"
1042                );
1043            } else {
1044                anyhow::bail!(
1045                    "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1046                    secret_key_string(&req),
1047                    source
1048                );
1049            }
1050        }
1051        let scope = req.scope.as_ref().expect("scope present");
1052        let fmt = fmt_key(&req);
1053        let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1054        if let Some(existing) = merged.get_mut(&key_tuple) {
1055            merge_requirement(existing, &req);
1056        } else {
1057            merged.insert(key_tuple, req);
1058        }
1059        Ok(())
1060    };
1061
1062    for component in components {
1063        if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1064            for req in &secret_caps.required {
1065                process_req(req, &component.id)?;
1066            }
1067        }
1068    }
1069
1070    if let Some(path) = override_path {
1071        let contents = fs::read_to_string(path)
1072            .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1073        let value: serde_json::Value = if path
1074            .extension()
1075            .and_then(|ext| ext.to_str())
1076            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1077            .unwrap_or(false)
1078        {
1079            let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1080                .with_context(|| format!("{} is not valid YAML", path.display()))?;
1081            serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1082        } else {
1083            serde_json::from_str(&contents)
1084                .with_context(|| format!("{} is not valid JSON", path.display()))?
1085        };
1086
1087        let overrides: Vec<SecretRequirement> =
1088            serde_json::from_value(value).with_context(|| {
1089                format!(
1090                    "{} must be an array of secret requirements (migration bridge)",
1091                    path.display()
1092                )
1093            })?;
1094        for req in &overrides {
1095            process_req(req, &format!("override:{}", path.display()))?;
1096        }
1097    }
1098
1099    let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1100    out.sort_by(|a, b| {
1101        let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1102        let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1103        (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1104            b_scope,
1105            secret_key_string(b),
1106            fmt_key(b),
1107        ))
1108    });
1109    Ok(out)
1110}
1111
1112fn fmt_key(req: &SecretRequirement) -> String {
1113    req.format
1114        .as_ref()
1115        .map(|f| format!("{:?}", f))
1116        .unwrap_or_else(|| "unspecified".to_string())
1117}
1118
1119fn scope_key(scope: &SecretScope) -> String {
1120    format!(
1121        "{}/{}/{}",
1122        &scope.env,
1123        &scope.tenant,
1124        scope
1125            .team
1126            .as_deref()
1127            .map(|t| t.to_string())
1128            .unwrap_or_else(|| "_".to_string())
1129    )
1130}
1131
1132fn secret_key_string(req: &SecretRequirement) -> String {
1133    let key: String = req.key.clone().into();
1134    key
1135}
1136
1137fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1138    if base.description.is_none() {
1139        base.description = incoming.description.clone();
1140    }
1141    if let Some(schema) = &incoming.schema {
1142        if base.schema.is_none() {
1143            base.schema = Some(schema.clone());
1144        } else if base.schema.as_ref() != Some(schema) {
1145            tracing::warn!(
1146                key = %secret_key_string(base),
1147                "conflicting secret schema encountered; keeping first"
1148            );
1149        }
1150    }
1151
1152    if !incoming.examples.is_empty() {
1153        for example in &incoming.examples {
1154            if !base.examples.contains(example) {
1155                base.examples.push(example.clone());
1156            }
1157        }
1158    }
1159
1160    base.required = base.required || incoming.required;
1161}
1162
1163fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1164    let parts: Vec<_> = raw.split('/').collect();
1165    if parts.len() < 2 || parts.len() > 3 {
1166        anyhow::bail!(
1167            "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1168            raw
1169        );
1170    }
1171    Ok(SecretScope {
1172        env: parts[0].to_string(),
1173        tenant: parts[1].to_string(),
1174        team: parts.get(2).map(|s| s.to_string()),
1175    })
1176}
1177
1178fn write_secret_requirements_file(
1179    pack_root: &Path,
1180    requirements: &[SecretRequirement],
1181    logical_name: &str,
1182) -> Result<PathBuf> {
1183    let path = pack_root.join(".packc").join(logical_name);
1184    if let Some(parent) = path.parent() {
1185        fs::create_dir_all(parent)
1186            .with_context(|| format!("failed to create {}", parent.display()))?;
1187    }
1188    let data = serde_json::to_vec_pretty(&requirements)
1189        .context("failed to serialise secret requirements")?;
1190    fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1191    Ok(path)
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::config::BootstrapConfig;
1198    use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1199    use greentic_types::flow::FlowKind;
1200    use serde_json::json;
1201    use std::io::Read;
1202    use std::{fs, path::PathBuf};
1203    use tempfile::tempdir;
1204    use zip::ZipArchive;
1205
1206    #[test]
1207    fn map_kind_accepts_known_values() {
1208        assert!(matches!(
1209            map_kind("application").unwrap(),
1210            PackKind::Application
1211        ));
1212        assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1213        assert!(matches!(
1214            map_kind("infrastructure").unwrap(),
1215            PackKind::Infrastructure
1216        ));
1217        assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1218        assert!(map_kind("unknown").is_err());
1219    }
1220
1221    #[test]
1222    fn collect_assets_preserves_relative_paths() {
1223        let root = PathBuf::from("/packs/demo");
1224        let assets = vec![AssetConfig {
1225            path: root.join("assets").join("foo.txt"),
1226        }];
1227        let collected = collect_assets(&assets, &root).expect("collect assets");
1228        assert_eq!(collected[0].logical_path, "assets/foo.txt");
1229    }
1230
1231    #[test]
1232    fn build_bootstrap_requires_known_references() {
1233        let config = pack_config_with_bootstrap(BootstrapConfig {
1234            install_flow: Some("flow.a".to_string()),
1235            upgrade_flow: None,
1236            installer_component: Some("component.a".to_string()),
1237        });
1238        let flows = vec![flow_entry("flow.a")];
1239        let components = vec![minimal_component_manifest("component.a")];
1240
1241        let bootstrap = build_bootstrap(&config, &flows, &components)
1242            .expect("bootstrap populated")
1243            .expect("bootstrap present");
1244
1245        assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
1246        assert_eq!(bootstrap.upgrade_flow, None);
1247        assert_eq!(
1248            bootstrap.installer_component.as_deref(),
1249            Some("component.a")
1250        );
1251    }
1252
1253    #[test]
1254    fn build_bootstrap_rejects_unknown_flow() {
1255        let config = pack_config_with_bootstrap(BootstrapConfig {
1256            install_flow: Some("missing".to_string()),
1257            upgrade_flow: None,
1258            installer_component: Some("component.a".to_string()),
1259        });
1260        let flows = vec![flow_entry("flow.a")];
1261        let components = vec![minimal_component_manifest("component.a")];
1262
1263        let err = build_bootstrap(&config, &flows, &components).unwrap_err();
1264        assert!(
1265            err.to_string()
1266                .contains("bootstrap.install_flow references unknown flow"),
1267            "unexpected error: {err}"
1268        );
1269    }
1270
1271    #[test]
1272    fn component_manifest_without_dev_flows_defaults_to_empty() {
1273        let manifest: ComponentManifest = serde_json::from_value(json!({
1274            "id": "component.dev",
1275            "version": "1.0.0",
1276            "supports": ["messaging"],
1277            "world": "greentic:demo@1.0.0",
1278            "profiles": { "default": "default", "supported": ["default"] },
1279            "capabilities": { "wasi": {}, "host": {} },
1280            "operations": [],
1281            "resources": {}
1282        }))
1283        .expect("manifest without dev_flows");
1284
1285        assert!(manifest.dev_flows.is_empty());
1286
1287        let pack_manifest = pack_manifest_with_component(manifest.clone());
1288        let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1289        let decoded: PackManifest =
1290            greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
1291        let stored = decoded
1292            .components
1293            .iter()
1294            .find(|item| item.id == manifest.id)
1295            .expect("component present");
1296        assert!(stored.dev_flows.is_empty());
1297    }
1298
1299    #[test]
1300    fn dev_flows_round_trip_in_manifest_and_gtpack() {
1301        let component = manifest_with_dev_flow();
1302        let pack_manifest = pack_manifest_with_component(component.clone());
1303        let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1304
1305        let decoded: PackManifest =
1306            greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
1307        let decoded_component = decoded
1308            .components
1309            .iter()
1310            .find(|item| item.id == component.id)
1311            .expect("component present");
1312        assert_eq!(decoded_component.dev_flows, component.dev_flows);
1313
1314        let temp = tempdir().expect("temp dir");
1315        let wasm_path = temp.path().join("component.wasm");
1316        write_stub_wasm(&wasm_path).expect("write stub wasm");
1317
1318        let build = BuildProducts {
1319            manifest: pack_manifest,
1320            components: vec![ComponentBinary {
1321                id: component.id.to_string(),
1322                source: wasm_path,
1323                manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
1324                manifest_path: format!("components/{}.manifest.cbor", component.id),
1325                manifest_hash_sha256: {
1326                    let mut sha = Sha256::new();
1327                    sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
1328                    format!("sha256:{:x}", sha.finalize())
1329                },
1330            }],
1331            assets: Vec::new(),
1332        };
1333
1334        let out = temp.path().join("demo.gtpack");
1335        package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
1336
1337        let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
1338            .expect("read gtpack archive");
1339        let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
1340        let mut stored = Vec::new();
1341        manifest_entry
1342            .read_to_end(&mut stored)
1343            .expect("read manifest");
1344        let decoded: PackManifest =
1345            greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
1346
1347        let stored_component = decoded
1348            .components
1349            .iter()
1350            .find(|item| item.id == component.id)
1351            .expect("component preserved");
1352        assert_eq!(stored_component.dev_flows, component.dev_flows);
1353    }
1354
1355    #[test]
1356    fn component_sources_extension_respects_bundle() {
1357        let lock = PackLockV1::new(vec![LockedComponent {
1358            name: "demo.component".into(),
1359            r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
1360            digest: "sha256:deadbeef".into(),
1361        }]);
1362
1363        let ext_none =
1364            merge_component_sources_extension(None, &lock, BundleMode::None).expect("ext");
1365        let value = match ext_none
1366            .unwrap()
1367            .get(EXT_COMPONENT_SOURCES_V1)
1368            .and_then(|e| e.inline.as_ref())
1369        {
1370            Some(ExtensionInline::Other(v)) => v.clone(),
1371            _ => panic!("missing inline"),
1372        };
1373        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1374        assert!(matches!(
1375            decoded.components[0].artifact,
1376            ArtifactLocationV1::Remote
1377        ));
1378
1379        let ext_cache =
1380            merge_component_sources_extension(None, &lock, BundleMode::Cache).expect("ext");
1381        let value = match ext_cache
1382            .unwrap()
1383            .get(EXT_COMPONENT_SOURCES_V1)
1384            .and_then(|e| e.inline.as_ref())
1385        {
1386            Some(ExtensionInline::Other(v)) => v.clone(),
1387            _ => panic!("missing inline"),
1388        };
1389        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1390        assert!(matches!(
1391            decoded.components[0].artifact,
1392            ArtifactLocationV1::Inline { .. }
1393        ));
1394    }
1395
1396    #[test]
1397    fn aggregate_secret_requirements_dedupes_and_sorts() {
1398        let component: ComponentConfig = serde_json::from_value(json!({
1399            "id": "component.a",
1400            "version": "1.0.0",
1401            "world": "greentic:demo@1.0.0",
1402            "supports": [],
1403            "profiles": { "default": "default", "supported": ["default"] },
1404            "capabilities": {
1405                "wasi": {},
1406                "host": {
1407                    "secrets": {
1408                        "required": [
1409                    {
1410                        "key": "db/password",
1411                        "required": true,
1412                        "scope": { "env": "dev", "tenant": "t1" },
1413                        "format": "text",
1414                        "description": "primary"
1415                    }
1416                ]
1417            }
1418        }
1419            },
1420            "wasm": "component.wasm",
1421            "operations": [],
1422            "resources": {}
1423        }))
1424        .expect("component config");
1425
1426        let dupe: ComponentConfig = serde_json::from_value(json!({
1427            "id": "component.b",
1428            "version": "1.0.0",
1429            "world": "greentic:demo@1.0.0",
1430            "supports": [],
1431            "profiles": { "default": "default", "supported": ["default"] },
1432            "capabilities": {
1433                "wasi": {},
1434                "host": {
1435                    "secrets": {
1436                        "required": [
1437                            {
1438                        "key": "db/password",
1439                        "required": true,
1440                        "scope": { "env": "dev", "tenant": "t1" },
1441                        "format": "text",
1442                        "description": "secondary",
1443                        "examples": ["example"]
1444                    }
1445                ]
1446            }
1447                }
1448            },
1449            "wasm": "component.wasm",
1450            "operations": [],
1451            "resources": {}
1452        }))
1453        .expect("component config");
1454
1455        let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
1456            .expect("aggregate secrets");
1457        assert_eq!(reqs.len(), 1);
1458        let req = &reqs[0];
1459        assert_eq!(req.description.as_deref(), Some("primary"));
1460        assert!(req.examples.contains(&"example".to_string()));
1461    }
1462
1463    fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
1464        PackConfig {
1465            pack_id: "demo.pack".to_string(),
1466            version: "1.0.0".to_string(),
1467            kind: "application".to_string(),
1468            publisher: "demo".to_string(),
1469            bootstrap: Some(bootstrap),
1470            components: Vec::new(),
1471            dependencies: Vec::new(),
1472            flows: Vec::new(),
1473            assets: Vec::new(),
1474            extensions: None,
1475        }
1476    }
1477
1478    fn flow_entry(id: &str) -> PackFlowEntry {
1479        let flow: Flow = serde_json::from_value(json!({
1480            "schema_version": "flow/v1",
1481            "id": id,
1482            "kind": "messaging"
1483        }))
1484        .expect("flow json");
1485
1486        PackFlowEntry {
1487            id: FlowId::new(id).expect("flow id"),
1488            kind: FlowKind::Messaging,
1489            flow,
1490            tags: Vec::new(),
1491            entrypoints: Vec::new(),
1492        }
1493    }
1494
1495    fn minimal_component_manifest(id: &str) -> ComponentManifest {
1496        serde_json::from_value(json!({
1497            "id": id,
1498            "version": "1.0.0",
1499            "supports": [],
1500            "world": "greentic:demo@1.0.0",
1501            "profiles": { "default": "default", "supported": ["default"] },
1502            "capabilities": { "wasi": {}, "host": {} },
1503            "operations": [],
1504            "resources": {}
1505        }))
1506        .expect("component manifest")
1507    }
1508
1509    fn manifest_with_dev_flow() -> ComponentManifest {
1510        serde_json::from_str(include_str!(
1511            "../tests/fixtures/component_manifest_with_dev_flows.json"
1512        ))
1513        .expect("fixture manifest")
1514    }
1515
1516    fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
1517        let flow = serde_json::from_value(json!({
1518            "schema_version": "flow/v1",
1519            "id": "flow.dev",
1520            "kind": "messaging"
1521        }))
1522        .expect("flow json");
1523
1524        PackManifest {
1525            schema_version: "pack-v1".to_string(),
1526            pack_id: PackId::new("demo.pack").expect("pack id"),
1527            version: Version::parse("1.0.0").expect("version"),
1528            kind: PackKind::Application,
1529            publisher: "demo".to_string(),
1530            components: vec![component],
1531            flows: vec![PackFlowEntry {
1532                id: FlowId::new("flow.dev").expect("flow id"),
1533                kind: FlowKind::Messaging,
1534                flow,
1535                tags: Vec::new(),
1536                entrypoints: Vec::new(),
1537            }],
1538            dependencies: Vec::new(),
1539            capabilities: Vec::new(),
1540            secret_requirements: Vec::new(),
1541            signatures: PackSignatures::default(),
1542            bootstrap: None,
1543            extensions: None,
1544        }
1545    }
1546}