Skip to main content

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::load_flow_resolve_summary;
7use crate::runtime::{NetworkPolicy, RuntimeContext};
8use anyhow::{Context, Result, anyhow};
9use greentic_distributor_client::{DistClient, DistOptions};
10use greentic_flow::add_step::normalize::normalize_node_map;
11use greentic_flow::compile_ygtc_file;
12use greentic_flow::loader::load_ygtc_from_path;
13use greentic_pack::builder::SbomEntry;
14use greentic_pack::pack_lock::read_pack_lock;
15use greentic_types::component_source::ComponentSourceRef;
16use greentic_types::flow_resolve_summary::FlowResolveSummaryV1;
17use greentic_types::pack::extensions::component_manifests::{
18    ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
19    ManifestEncoding,
20};
21use greentic_types::pack::extensions::component_sources::{
22    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
23    ResolvedComponentV1,
24};
25use greentic_types::{
26    BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
27    ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
28    PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
29    encode_pack_manifest,
30};
31use semver::Version;
32use serde::Serialize;
33use serde_cbor;
34use serde_yaml_bw::Value as YamlValue;
35use sha2::{Digest, Sha256};
36use std::collections::{BTreeMap, BTreeSet};
37use std::fs;
38use std::io::Write;
39use std::path::{Path, PathBuf};
40use std::str::FromStr;
41use tracing::info;
42use zip::write::SimpleFileOptions;
43use zip::{CompressionMethod, ZipWriter};
44
45const SBOM_FORMAT: &str = "greentic-sbom-v1";
46
47#[derive(Serialize)]
48struct SbomDocument {
49    format: String,
50    files: Vec<SbomEntry>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
54pub enum BundleMode {
55    Cache,
56    None,
57}
58
59#[derive(Clone)]
60pub struct BuildOptions {
61    pub pack_dir: PathBuf,
62    pub component_out: Option<PathBuf>,
63    pub manifest_out: PathBuf,
64    pub sbom_out: Option<PathBuf>,
65    pub gtpack_out: Option<PathBuf>,
66    pub lock_path: PathBuf,
67    pub bundle: BundleMode,
68    pub dry_run: bool,
69    pub secrets_req: Option<PathBuf>,
70    pub default_secret_scope: Option<String>,
71    pub allow_oci_tags: bool,
72    pub require_component_manifests: bool,
73    pub runtime: RuntimeContext,
74    pub skip_update: bool,
75}
76
77impl BuildOptions {
78    pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
79        let pack_dir = args
80            .input
81            .canonicalize()
82            .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
83
84        let component_out = args
85            .component_out
86            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
87        let manifest_out = args
88            .manifest
89            .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
90            .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
91        let sbom_out = args
92            .sbom
93            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
94        let default_gtpack_name = pack_dir
95            .file_name()
96            .and_then(|name| name.to_str())
97            .unwrap_or("pack");
98        let default_gtpack_out = pack_dir
99            .join("dist")
100            .join(format!("{default_gtpack_name}.gtpack"));
101        let gtpack_out = Some(
102            args.gtpack_out
103                .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
104                .unwrap_or(default_gtpack_out),
105        );
106        let lock_path = args
107            .lock
108            .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
109            .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
110
111        Ok(Self {
112            pack_dir,
113            component_out,
114            manifest_out,
115            sbom_out,
116            gtpack_out,
117            lock_path,
118            bundle: args.bundle,
119            dry_run: args.dry_run,
120            secrets_req: args.secrets_req,
121            default_secret_scope: args.default_secret_scope,
122            allow_oci_tags: args.allow_oci_tags,
123            require_component_manifests: args.require_component_manifests,
124            runtime: runtime.clone(),
125            skip_update: args.no_update,
126        })
127    }
128}
129
130pub async fn run(opts: &BuildOptions) -> Result<()> {
131    info!(
132        pack_dir = %opts.pack_dir.display(),
133        manifest_out = %opts.manifest_out.display(),
134        gtpack_out = ?opts.gtpack_out,
135        dry_run = opts.dry_run,
136        "building greentic pack"
137    );
138
139    if !opts.skip_update {
140        // Keep pack.yaml in sync before building.
141        crate::cli::update::update_pack(&opts.pack_dir, false)?;
142    }
143
144    // Resolve component references into pack.lock.json before building to ensure
145    // manifests/extensions can rely on the lockfile contents.
146    resolve::handle(
147        ResolveArgs {
148            input: opts.pack_dir.clone(),
149            lock: Some(opts.lock_path.clone()),
150        },
151        &opts.runtime,
152        false,
153    )
154    .await?;
155
156    let config = crate::config::load_pack_config(&opts.pack_dir)?;
157    info!(
158        id = %config.pack_id,
159        version = %config.version,
160        kind = %config.kind,
161        components = config.components.len(),
162        flows = config.flows.len(),
163        dependencies = config.dependencies.len(),
164        "loaded pack.yaml"
165    );
166    validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
167
168    let secret_requirements = aggregate_secret_requirements(
169        &config.components,
170        opts.secrets_req.as_deref(),
171        opts.default_secret_scope.as_deref(),
172    )?;
173
174    if !opts.lock_path.exists() {
175        anyhow::bail!(
176            "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
177            opts.lock_path.display()
178        );
179    }
180    let mut pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
181        format!(
182            "failed to read pack lock {} (try `greentic-pack resolve`)",
183            opts.lock_path.display()
184        )
185    })?;
186
187    let mut build = assemble_manifest(&config, &opts.pack_dir, &secret_requirements)?;
188    build.lock_components =
189        collect_lock_component_artifacts(&mut pack_lock, &opts.runtime, opts.bundle, opts.dry_run)
190            .await?;
191
192    let materialized = materialize_flow_components(
193        &opts.pack_dir,
194        &build.manifest.flows,
195        &pack_lock,
196        &build.components,
197        &build.lock_components,
198        opts.require_component_manifests,
199    )?;
200    build.manifest.components.extend(materialized.components);
201    build.component_manifest_files = materialized.manifest_files;
202    build.manifest.components.sort_by(|a, b| a.id.cmp(&b.id));
203
204    let component_manifest_files =
205        collect_component_manifest_files(&build.components, &build.component_manifest_files);
206    build.manifest.extensions =
207        merge_component_manifest_extension(build.manifest.extensions, &component_manifest_files)?;
208    build.manifest.extensions = merge_component_sources_extension(
209        build.manifest.extensions,
210        &pack_lock,
211        opts.bundle,
212        materialized.manifest_paths.as_ref(),
213    )?;
214    if !opts.dry_run {
215        greentic_pack::pack_lock::write_pack_lock(&opts.lock_path, &pack_lock)?;
216    }
217
218    let manifest_bytes = encode_pack_manifest(&build.manifest)?;
219    info!(len = manifest_bytes.len(), "encoded manifest.cbor");
220
221    if opts.dry_run {
222        info!("dry-run complete; no files written");
223        return Ok(());
224    }
225
226    if let Some(component_out) = opts.component_out.as_ref() {
227        write_stub_wasm(component_out)?;
228    }
229
230    write_bytes(&opts.manifest_out, &manifest_bytes)?;
231
232    if let Some(sbom_out) = opts.sbom_out.as_ref() {
233        write_bytes(sbom_out, br#"{"files":[]} "#)?;
234    }
235
236    if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
237        let mut build = build;
238        if !secret_requirements.is_empty() {
239            let logical = "secret-requirements.json".to_string();
240            let req_path =
241                write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
242            build.assets.push(AssetFile {
243                logical_path: logical,
244                source: req_path,
245            });
246        }
247        package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle)?;
248        info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
249        eprintln!("wrote {}", gtpack_out.display());
250    }
251
252    Ok(())
253}
254
255struct BuildProducts {
256    manifest: PackManifest,
257    components: Vec<ComponentBinary>,
258    lock_components: Vec<LockComponentBinary>,
259    component_manifest_files: Vec<ComponentManifestFile>,
260    flow_files: Vec<FlowFile>,
261    assets: Vec<AssetFile>,
262}
263
264#[derive(Clone)]
265struct ComponentBinary {
266    id: String,
267    source: PathBuf,
268    manifest_bytes: Vec<u8>,
269    manifest_path: String,
270    manifest_hash_sha256: String,
271}
272
273#[derive(Clone)]
274struct LockComponentBinary {
275    logical_path: String,
276    source: PathBuf,
277}
278
279#[derive(Clone)]
280struct ComponentManifestFile {
281    component_id: String,
282    manifest_path: String,
283    manifest_bytes: Vec<u8>,
284    manifest_hash_sha256: String,
285}
286
287struct AssetFile {
288    logical_path: String,
289    source: PathBuf,
290}
291
292#[derive(Clone)]
293struct FlowFile {
294    logical_path: String,
295    bytes: Vec<u8>,
296    media_type: &'static str,
297}
298
299fn assemble_manifest(
300    config: &PackConfig,
301    pack_root: &Path,
302    secret_requirements: &[SecretRequirement],
303) -> Result<BuildProducts> {
304    let components = build_components(&config.components)?;
305    let (flows, flow_files) = build_flows(&config.flows, pack_root)?;
306    let dependencies = build_dependencies(&config.dependencies)?;
307    let assets = collect_assets(&config.assets, pack_root)?;
308    let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
309    let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
310    let extensions = normalize_extensions(&config.extensions);
311
312    let manifest = PackManifest {
313        schema_version: "pack-v1".to_string(),
314        pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
315        version: Version::parse(&config.version)
316            .context("invalid pack version (expected semver)")?,
317        kind: map_kind(&config.kind)?,
318        publisher: config.publisher.clone(),
319        components: component_manifests,
320        flows,
321        dependencies,
322        capabilities: derive_pack_capabilities(&components),
323        secret_requirements: secret_requirements.to_vec(),
324        signatures: PackSignatures::default(),
325        bootstrap,
326        extensions,
327    };
328
329    Ok(BuildProducts {
330        manifest,
331        components: components.into_iter().map(|(_, bin)| bin).collect(),
332        lock_components: Vec::new(),
333        component_manifest_files: Vec::new(),
334        flow_files,
335        assets,
336    })
337}
338
339fn build_components(
340    configs: &[ComponentConfig],
341) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
342    let mut seen = BTreeSet::new();
343    let mut result = Vec::new();
344
345    for cfg in configs {
346        if !seen.insert(cfg.id.clone()) {
347            anyhow::bail!("duplicate component id {}", cfg.id);
348        }
349
350        info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
351        let (manifest, binary) = resolve_component_artifacts(cfg)?;
352
353        result.push((manifest, binary));
354    }
355
356    Ok(result)
357}
358
359fn resolve_component_artifacts(
360    cfg: &ComponentConfig,
361) -> Result<(ComponentManifest, ComponentBinary)> {
362    let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
363
364    let mut manifest = if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm)? {
365        if from_disk.id.to_string() != cfg.id {
366            anyhow::bail!(
367                "component manifest id {} does not match pack.yaml id {}",
368                from_disk.id,
369                cfg.id
370            );
371        }
372        if from_disk.version.to_string() != cfg.version {
373            anyhow::bail!(
374                "component manifest version {} does not match pack.yaml version {}",
375                from_disk.version,
376                cfg.version
377            );
378        }
379        from_disk
380    } else {
381        manifest_from_config(cfg)?
382    };
383
384    // Ensure operations are populated from pack.yaml when missing in the on-disk manifest.
385    if manifest.operations.is_empty() && !cfg.operations.is_empty() {
386        manifest.operations = cfg
387            .operations
388            .iter()
389            .map(operation_from_config)
390            .collect::<Result<Vec<_>>>()?;
391    }
392
393    let manifest_bytes =
394        serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
395    let mut sha = Sha256::new();
396    sha.update(&manifest_bytes);
397    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
398    let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
399
400    let binary = ComponentBinary {
401        id: cfg.id.clone(),
402        source: resolved_wasm,
403        manifest_bytes,
404        manifest_path,
405        manifest_hash_sha256,
406    };
407
408    Ok((manifest, binary))
409}
410
411fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
412    Ok(ComponentManifest {
413        id: ComponentId::new(cfg.id.clone())
414            .with_context(|| format!("invalid component id {}", cfg.id))?,
415        version: Version::parse(&cfg.version)
416            .context("invalid component version (expected semver)")?,
417        supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
418        world: cfg.world.clone(),
419        profiles: cfg.profiles.clone(),
420        capabilities: cfg.capabilities.clone(),
421        configurators: convert_configurators(cfg)?,
422        operations: cfg
423            .operations
424            .iter()
425            .map(operation_from_config)
426            .collect::<Result<Vec<_>>>()?,
427        config_schema: cfg.config_schema.clone(),
428        resources: cfg.resources.clone().unwrap_or_default(),
429        dev_flows: BTreeMap::new(),
430    })
431}
432
433fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
434    if path.is_file() {
435        return Ok(path.to_path_buf());
436    }
437    if !path.exists() {
438        anyhow::bail!("component path {} does not exist", path.display());
439    }
440    if !path.is_dir() {
441        anyhow::bail!(
442            "component path {} must be a file or directory",
443            path.display()
444        );
445    }
446
447    let mut component_candidates = Vec::new();
448    let mut wasm_candidates = Vec::new();
449    let mut stack = vec![path.to_path_buf()];
450    while let Some(current) = stack.pop() {
451        for entry in fs::read_dir(&current)
452            .with_context(|| format!("failed to list components in {}", current.display()))?
453        {
454            let entry = entry?;
455            let entry_type = entry.file_type()?;
456            let entry_path = entry.path();
457            if entry_type.is_dir() {
458                stack.push(entry_path);
459                continue;
460            }
461            if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
462            {
463                let file_name = entry_path
464                    .file_name()
465                    .and_then(|n| n.to_str())
466                    .unwrap_or_default();
467                if file_name.ends_with(".component.wasm") {
468                    component_candidates.push(entry_path);
469                } else {
470                    wasm_candidates.push(entry_path);
471                }
472            }
473        }
474    }
475
476    let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
477        list.sort();
478        if list.len() == 1 {
479            Ok(list.remove(0))
480        } else {
481            let options = list
482                .iter()
483                .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
484                .collect::<Vec<_>>()
485                .join(", ");
486            anyhow::bail!(
487                "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
488                path.display(),
489                options
490            );
491        }
492    };
493
494    if !component_candidates.is_empty() {
495        return choose(component_candidates);
496    }
497    if !wasm_candidates.is_empty() {
498        return choose(wasm_candidates);
499    }
500
501    anyhow::bail!(
502        "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
503        path.display()
504    );
505}
506
507fn load_component_manifest_from_disk(path: &Path) -> Result<Option<ComponentManifest>> {
508    let manifest_dir = if path.is_dir() {
509        path.to_path_buf()
510    } else {
511        path.parent()
512            .map(Path::to_path_buf)
513            .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
514    };
515    let candidates = [
516        manifest_dir.join("component.manifest.cbor"),
517        manifest_dir.join("component.manifest.json"),
518        manifest_dir.join("component.json"),
519    ];
520    for manifest_path in candidates {
521        if !manifest_path.exists() {
522            continue;
523        }
524        let manifest = load_component_manifest_from_file(&manifest_path)?;
525        return Ok(Some(manifest));
526    }
527
528    Ok(None)
529}
530
531fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
532    Ok(ComponentOperation {
533        name: cfg.name.clone(),
534        input_schema: cfg.input_schema.clone(),
535        output_schema: cfg.output_schema.clone(),
536    })
537}
538
539fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
540    let Some(configurators) = cfg.configurators.as_ref() else {
541        return Ok(None);
542    };
543
544    let basic = match &configurators.basic {
545        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
546        None => None,
547    };
548    let full = match &configurators.full {
549        Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
550        None => None,
551    };
552
553    Ok(Some(ComponentConfigurators { basic, full }))
554}
555
556fn build_bootstrap(
557    config: &PackConfig,
558    flows: &[PackFlowEntry],
559    components: &[ComponentManifest],
560) -> Result<Option<BootstrapSpec>> {
561    let Some(raw) = config.bootstrap.as_ref() else {
562        return Ok(None);
563    };
564
565    let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
566    let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
567
568    let mut spec = BootstrapSpec::default();
569
570    if let Some(install_flow) = &raw.install_flow {
571        if !flow_ids.contains(install_flow) {
572            anyhow::bail!(
573                "bootstrap.install_flow references unknown flow {}",
574                install_flow
575            );
576        }
577        spec.install_flow = Some(install_flow.clone());
578    }
579
580    if let Some(upgrade_flow) = &raw.upgrade_flow {
581        if !flow_ids.contains(upgrade_flow) {
582            anyhow::bail!(
583                "bootstrap.upgrade_flow references unknown flow {}",
584                upgrade_flow
585            );
586        }
587        spec.upgrade_flow = Some(upgrade_flow.clone());
588    }
589
590    if let Some(component) = &raw.installer_component {
591        if !component_ids.contains(component) {
592            anyhow::bail!(
593                "bootstrap.installer_component references unknown component {}",
594                component
595            );
596        }
597        spec.installer_component = Some(component.clone());
598    }
599
600    if spec.install_flow.is_none()
601        && spec.upgrade_flow.is_none()
602        && spec.installer_component.is_none()
603    {
604        return Ok(None);
605    }
606
607    Ok(Some(spec))
608}
609
610fn build_flows(
611    configs: &[FlowConfig],
612    pack_root: &Path,
613) -> Result<(Vec<PackFlowEntry>, Vec<FlowFile>)> {
614    let mut seen = BTreeSet::new();
615    let mut entries = Vec::new();
616    let mut flow_files = Vec::new();
617
618    for cfg in configs {
619        info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
620        let yaml_bytes = fs::read(&cfg.file)
621            .with_context(|| format!("failed to read flow {}", cfg.file.display()))?;
622        let mut flow: Flow = compile_ygtc_file(&cfg.file)
623            .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
624        populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
625            format!(
626                "failed to resolve component.exec operations in {}",
627                cfg.file.display()
628            )
629        })?;
630        normalize_legacy_component_exec_ids(&mut flow)?;
631        let summary = load_flow_resolve_summary(pack_root, cfg, &flow)?;
632        apply_summary_component_ids(&mut flow, &summary).with_context(|| {
633            format!("failed to resolve component ids in {}", cfg.file.display())
634        })?;
635
636        let flow_id = flow.id.to_string();
637        if !seen.insert(flow_id.clone()) {
638            anyhow::bail!("duplicate flow id {}", flow_id);
639        }
640
641        let entrypoints = if cfg.entrypoints.is_empty() {
642            flow.entrypoints.keys().cloned().collect()
643        } else {
644            cfg.entrypoints.clone()
645        };
646
647        let flow_entry = PackFlowEntry {
648            id: flow.id.clone(),
649            kind: flow.kind,
650            flow,
651            tags: cfg.tags.clone(),
652            entrypoints,
653        };
654
655        let flow_id = flow_entry.id.to_string();
656        flow_files.push(FlowFile {
657            logical_path: format!("flows/{flow_id}/flow.ygtc"),
658            bytes: yaml_bytes,
659            media_type: "application/yaml",
660        });
661        flow_files.push(FlowFile {
662            logical_path: format!("flows/{flow_id}/flow.json"),
663            bytes: serde_json::to_vec(&flow_entry.flow).context("encode flow json")?,
664            media_type: "application/json",
665        });
666        entries.push(flow_entry);
667    }
668
669    Ok((entries, flow_files))
670}
671
672fn apply_summary_component_ids(flow: &mut Flow, summary: &FlowResolveSummaryV1) -> Result<()> {
673    for (node_id, node) in flow.nodes.iter_mut() {
674        let resolved = summary.nodes.get(node_id.as_str()).ok_or_else(|| {
675            anyhow!(
676                "flow resolve summary missing node {} (expected component id for node)",
677                node_id
678            )
679        })?;
680        let summary_id = resolved.component_id.as_str();
681        if node.component.id.as_str().is_empty() || node.component.id.as_str() == "component.exec" {
682            node.component.id = resolved.component_id.clone();
683            continue;
684        }
685        if node.component.id.as_str() != summary_id {
686            anyhow::bail!(
687                "node {} component id {} does not match resolve summary {}",
688                node_id,
689                node.component.id.as_str(),
690                summary_id
691            );
692        }
693    }
694    Ok(())
695}
696
697fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
698    let needs_op = flow.nodes.values().any(|node| {
699        node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
700    });
701    if !needs_op {
702        return Ok(());
703    }
704
705    let flow_doc = load_ygtc_from_path(path)?;
706    let mut operations = BTreeMap::new();
707
708    for (node_id, node_doc) in flow_doc.nodes {
709        let value = serde_json::to_value(&node_doc)
710            .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
711        let normalized = normalize_node_map(value)?;
712        if !normalized.operation.trim().is_empty() {
713            operations.insert(node_id, normalized.operation);
714        }
715    }
716
717    for (node_id, node) in flow.nodes.iter_mut() {
718        if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
719            continue;
720        }
721        if let Some(op) = operations.get(node_id.as_str()) {
722            node.component.operation = Some(op.clone());
723        }
724    }
725
726    Ok(())
727}
728
729fn normalize_legacy_component_exec_ids(flow: &mut Flow) -> Result<()> {
730    for (node_id, node) in flow.nodes.iter_mut() {
731        if node.component.id.as_str() != "component.exec" {
732            continue;
733        }
734        let Some(op) = node.component.operation.as_deref() else {
735            continue;
736        };
737        if !op.contains('.') && !op.contains(':') {
738            continue;
739        }
740        node.component.id = ComponentId::new(op).with_context(|| {
741            format!("invalid component id {} resolved for node {}", op, node_id)
742        })?;
743        node.component.operation = None;
744    }
745    Ok(())
746}
747
748fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
749    let mut deps = Vec::new();
750    let mut seen = BTreeSet::new();
751    for cfg in configs {
752        if !seen.insert(cfg.alias.clone()) {
753            anyhow::bail!("duplicate dependency alias {}", cfg.alias);
754        }
755        deps.push(PackDependency {
756            alias: cfg.alias.clone(),
757            pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
758            version_req: SemverReq::parse(&cfg.version_req)
759                .context("invalid dependency version requirement")?,
760            required_capabilities: cfg.required_capabilities.clone(),
761        });
762    }
763    Ok(deps)
764}
765
766fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
767    let mut assets = Vec::new();
768    for cfg in configs {
769        let logical = cfg
770            .path
771            .strip_prefix(pack_root)
772            .unwrap_or(&cfg.path)
773            .components()
774            .map(|c| c.as_os_str().to_string_lossy().into_owned())
775            .collect::<Vec<_>>()
776            .join("/");
777        if logical.is_empty() {
778            anyhow::bail!("invalid asset path {}", cfg.path.display());
779        }
780        assets.push(AssetFile {
781            logical_path: logical,
782            source: cfg.path.clone(),
783        });
784    }
785    Ok(assets)
786}
787
788fn normalize_extensions(
789    extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
790) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
791    extensions.as_ref().filter(|map| !map.is_empty()).cloned()
792}
793
794fn merge_component_manifest_extension(
795    extensions: Option<BTreeMap<String, ExtensionRef>>,
796    manifest_files: &[ComponentManifestFile],
797) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
798    if manifest_files.is_empty() {
799        return Ok(extensions);
800    }
801
802    let entries: Vec<_> = manifest_files
803        .iter()
804        .map(|entry| ComponentManifestIndexEntryV1 {
805            component_id: entry.component_id.clone(),
806            manifest_file: entry.manifest_path.clone(),
807            encoding: ManifestEncoding::Cbor,
808            content_hash: Some(entry.manifest_hash_sha256.clone()),
809        })
810        .collect();
811
812    let index = ComponentManifestIndexV1::new(entries);
813    let value = index
814        .to_extension_value()
815        .context("serialize component manifest index extension")?;
816
817    let ext = ExtensionRef {
818        kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
819        version: "v1".to_string(),
820        digest: None,
821        location: None,
822        inline: Some(ExtensionInline::Other(value)),
823    };
824
825    let mut map = extensions.unwrap_or_default();
826    map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
827    if map.is_empty() {
828        Ok(None)
829    } else {
830        Ok(Some(map))
831    }
832}
833
834fn merge_component_sources_extension(
835    extensions: Option<BTreeMap<String, ExtensionRef>>,
836    lock: &greentic_pack::pack_lock::PackLockV1,
837    _bundle: BundleMode,
838    manifest_paths: Option<&std::collections::BTreeMap<String, String>>,
839) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
840    let mut entries = Vec::new();
841    for comp in &lock.components {
842        if comp.r#ref.starts_with("file://") {
843            continue;
844        }
845        let source = match ComponentSourceRef::from_str(&comp.r#ref) {
846            Ok(parsed) => parsed,
847            Err(_) => {
848                eprintln!(
849                    "warning: skipping pack.lock entry `{}` with unsupported ref {}",
850                    comp.name, comp.r#ref
851                );
852                continue;
853            }
854        };
855        let manifest_path = manifest_paths.and_then(|paths| {
856            comp.component_id
857                .as_ref()
858                .map(|id| id.as_str())
859                .and_then(|key| paths.get(key))
860                .or_else(|| paths.get(&comp.name))
861                .cloned()
862        });
863        let artifact = if comp.bundled {
864            let wasm_path = comp.bundled_path.clone().ok_or_else(|| {
865                anyhow!(
866                    "pack.lock entry {} marked bundled but missing bundled_path",
867                    comp.name
868                )
869            })?;
870            ArtifactLocationV1::Inline {
871                wasm_path,
872                manifest_path,
873            }
874        } else {
875            ArtifactLocationV1::Remote
876        };
877        entries.push(ComponentSourceEntryV1 {
878            name: comp.name.clone(),
879            component_id: comp.component_id.clone(),
880            source,
881            resolved: ResolvedComponentV1 {
882                digest: comp.digest.clone(),
883                signature: None,
884                signed_by: None,
885            },
886            artifact,
887            licensing_hint: None,
888            metering_hint: None,
889        });
890    }
891
892    if entries.is_empty() {
893        return Ok(extensions);
894    }
895
896    let payload = ComponentSourcesV1::new(entries)
897        .to_extension_value()
898        .context("serialize component_sources extension")?;
899
900    let ext = ExtensionRef {
901        kind: EXT_COMPONENT_SOURCES_V1.to_string(),
902        version: "v1".to_string(),
903        digest: None,
904        location: None,
905        inline: Some(ExtensionInline::Other(payload)),
906    };
907
908    let mut map = extensions.unwrap_or_default();
909    map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
910    if map.is_empty() {
911        Ok(None)
912    } else {
913        Ok(Some(map))
914    }
915}
916
917fn derive_pack_capabilities(
918    components: &[(ComponentManifest, ComponentBinary)],
919) -> Vec<ComponentCapability> {
920    let mut seen = BTreeSet::new();
921    let mut caps = Vec::new();
922
923    for (component, _) in components {
924        let mut add = |name: &str| {
925            if seen.insert(name.to_string()) {
926                caps.push(ComponentCapability {
927                    name: name.to_string(),
928                    description: None,
929                });
930            }
931        };
932
933        if component.capabilities.host.secrets.is_some() {
934            add("host:secrets");
935        }
936        if let Some(state) = &component.capabilities.host.state {
937            if state.read {
938                add("host:state:read");
939            }
940            if state.write {
941                add("host:state:write");
942            }
943        }
944        if component.capabilities.host.messaging.is_some() {
945            add("host:messaging");
946        }
947        if component.capabilities.host.events.is_some() {
948            add("host:events");
949        }
950        if component.capabilities.host.http.is_some() {
951            add("host:http");
952        }
953        if component.capabilities.host.telemetry.is_some() {
954            add("host:telemetry");
955        }
956        if component.capabilities.host.iac.is_some() {
957            add("host:iac");
958        }
959        if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
960            add(&format!(
961                "wasi:fs:{}",
962                format!("{:?}", fs.mode).to_lowercase()
963            ));
964            if !fs.mounts.is_empty() {
965                add("wasi:fs:mounts");
966            }
967        }
968        if component.capabilities.wasi.random {
969            add("wasi:random");
970        }
971        if component.capabilities.wasi.clocks {
972            add("wasi:clocks");
973        }
974    }
975
976    caps
977}
978
979fn map_kind(raw: &str) -> Result<PackKind> {
980    match raw.to_ascii_lowercase().as_str() {
981        "application" => Ok(PackKind::Application),
982        "provider" => Ok(PackKind::Provider),
983        "infrastructure" => Ok(PackKind::Infrastructure),
984        "library" => Ok(PackKind::Library),
985        other => Err(anyhow!("unknown pack kind {}", other)),
986    }
987}
988
989fn package_gtpack(
990    out_path: &Path,
991    manifest_bytes: &[u8],
992    build: &BuildProducts,
993    bundle: BundleMode,
994) -> Result<()> {
995    if let Some(parent) = out_path.parent() {
996        fs::create_dir_all(parent)
997            .with_context(|| format!("failed to create {}", parent.display()))?;
998    }
999
1000    let file = fs::File::create(out_path)
1001        .with_context(|| format!("failed to create {}", out_path.display()))?;
1002    let mut writer = ZipWriter::new(file);
1003    let options = SimpleFileOptions::default()
1004        .compression_method(CompressionMethod::Stored)
1005        .unix_permissions(0o644);
1006
1007    let mut sbom_entries = Vec::new();
1008    let mut written_paths = BTreeSet::new();
1009    record_sbom_entry(
1010        &mut sbom_entries,
1011        "manifest.cbor",
1012        manifest_bytes,
1013        "application/cbor",
1014    );
1015    written_paths.insert("manifest.cbor".to_string());
1016    write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
1017
1018    let mut flow_files = build.flow_files.clone();
1019    flow_files.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1020    for flow_file in flow_files {
1021        if written_paths.insert(flow_file.logical_path.clone()) {
1022            record_sbom_entry(
1023                &mut sbom_entries,
1024                &flow_file.logical_path,
1025                &flow_file.bytes,
1026                flow_file.media_type,
1027            );
1028            write_zip_entry(
1029                &mut writer,
1030                &flow_file.logical_path,
1031                &flow_file.bytes,
1032                options,
1033            )?;
1034        }
1035    }
1036
1037    let mut component_wasm_paths = BTreeSet::new();
1038    if bundle != BundleMode::None {
1039        for comp in &build.components {
1040            component_wasm_paths.insert(format!("components/{}.wasm", comp.id));
1041        }
1042    }
1043
1044    let mut lock_components = build.lock_components.clone();
1045    lock_components.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1046    for comp in lock_components {
1047        if component_wasm_paths.contains(&comp.logical_path) {
1048            continue;
1049        }
1050        if !written_paths.insert(comp.logical_path.clone()) {
1051            continue;
1052        }
1053        let bytes = fs::read(&comp.source).with_context(|| {
1054            format!("failed to read cached component {}", comp.source.display())
1055        })?;
1056        record_sbom_entry(
1057            &mut sbom_entries,
1058            &comp.logical_path,
1059            &bytes,
1060            "application/wasm",
1061        );
1062        write_zip_entry(&mut writer, &comp.logical_path, &bytes, options)?;
1063    }
1064
1065    let mut lock_manifests = build.component_manifest_files.clone();
1066    lock_manifests.sort_by(|a, b| a.manifest_path.cmp(&b.manifest_path));
1067    for manifest in lock_manifests {
1068        if written_paths.insert(manifest.manifest_path.clone()) {
1069            record_sbom_entry(
1070                &mut sbom_entries,
1071                &manifest.manifest_path,
1072                &manifest.manifest_bytes,
1073                "application/cbor",
1074            );
1075            write_zip_entry(
1076                &mut writer,
1077                &manifest.manifest_path,
1078                &manifest.manifest_bytes,
1079                options,
1080            )?;
1081        }
1082    }
1083
1084    if bundle != BundleMode::None {
1085        let mut components = build.components.clone();
1086        components.sort_by(|a, b| a.id.cmp(&b.id));
1087        for comp in components {
1088            let logical_wasm = format!("components/{}.wasm", comp.id);
1089            let wasm_bytes = fs::read(&comp.source)
1090                .with_context(|| format!("failed to read component {}", comp.source.display()))?;
1091            if written_paths.insert(logical_wasm.clone()) {
1092                record_sbom_entry(
1093                    &mut sbom_entries,
1094                    &logical_wasm,
1095                    &wasm_bytes,
1096                    "application/wasm",
1097                );
1098                write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
1099            }
1100
1101            if written_paths.insert(comp.manifest_path.clone()) {
1102                record_sbom_entry(
1103                    &mut sbom_entries,
1104                    &comp.manifest_path,
1105                    &comp.manifest_bytes,
1106                    "application/cbor",
1107                );
1108                write_zip_entry(
1109                    &mut writer,
1110                    &comp.manifest_path,
1111                    &comp.manifest_bytes,
1112                    options,
1113                )?;
1114            }
1115        }
1116    }
1117
1118    let mut asset_entries: Vec<_> = build
1119        .assets
1120        .iter()
1121        .map(|a| (format!("assets/{}", &a.logical_path), a.source.clone()))
1122        .collect();
1123    asset_entries.sort_by(|a, b| a.0.cmp(&b.0));
1124    for (logical, source) in asset_entries {
1125        let bytes = fs::read(&source)
1126            .with_context(|| format!("failed to read asset {}", source.display()))?;
1127        if written_paths.insert(logical.clone()) {
1128            record_sbom_entry(
1129                &mut sbom_entries,
1130                &logical,
1131                &bytes,
1132                "application/octet-stream",
1133            );
1134            write_zip_entry(&mut writer, &logical, &bytes, options)?;
1135        }
1136    }
1137
1138    sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
1139    let sbom_doc = SbomDocument {
1140        format: SBOM_FORMAT.to_string(),
1141        files: sbom_entries,
1142    };
1143    let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
1144    write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
1145
1146    writer
1147        .finish()
1148        .context("failed to finalise gtpack archive")?;
1149    Ok(())
1150}
1151
1152async fn collect_lock_component_artifacts(
1153    lock: &mut greentic_pack::pack_lock::PackLockV1,
1154    runtime: &RuntimeContext,
1155    bundle: BundleMode,
1156    allow_missing: bool,
1157) -> Result<Vec<LockComponentBinary>> {
1158    let dist = DistClient::new(DistOptions {
1159        cache_dir: runtime.cache_dir(),
1160        allow_tags: true,
1161        offline: runtime.network_policy() == NetworkPolicy::Offline,
1162        allow_insecure_local_http: false,
1163    });
1164
1165    let mut artifacts = Vec::new();
1166    let mut seen_paths = BTreeSet::new();
1167    for comp in &mut lock.components {
1168        if comp.r#ref.starts_with("file://") {
1169            comp.bundled = false;
1170            comp.bundled_path = None;
1171            comp.wasm_sha256 = None;
1172            comp.resolved_digest = None;
1173            continue;
1174        }
1175        let parsed = ComponentSourceRef::from_str(&comp.r#ref).ok();
1176        let is_tag = parsed.as_ref().map(|r| r.is_tag()).unwrap_or(false);
1177        let should_bundle = is_tag || bundle == BundleMode::Cache;
1178        if !should_bundle {
1179            comp.bundled = false;
1180            comp.bundled_path = None;
1181            comp.wasm_sha256 = None;
1182            comp.resolved_digest = None;
1183            continue;
1184        }
1185
1186        let resolved = if is_tag {
1187            let item = if runtime.network_policy() == NetworkPolicy::Offline {
1188                dist.ensure_cached(&comp.digest).await.map_err(|err| {
1189                    anyhow!(
1190                        "tag ref {} must be bundled but cache is missing ({})",
1191                        comp.r#ref,
1192                        err
1193                    )
1194                })?
1195            } else {
1196                dist.resolve_ref(&comp.r#ref)
1197                    .await
1198                    .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?
1199            };
1200            let cache_path = item.cache_path.clone().ok_or_else(|| {
1201                anyhow!("tag ref {} resolved but cache path is missing", comp.r#ref)
1202            })?;
1203            ResolvedLockItem { item, cache_path }
1204        } else {
1205            let mut resolved = dist
1206                .ensure_cached(&comp.digest)
1207                .await
1208                .ok()
1209                .and_then(|item| item.cache_path.clone().map(|path| (item, path)));
1210            if resolved.is_none()
1211                && runtime.network_policy() != NetworkPolicy::Offline
1212                && !allow_missing
1213                && comp.r#ref.starts_with("oci://")
1214            {
1215                let item = dist
1216                    .resolve_ref(&comp.r#ref)
1217                    .await
1218                    .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?;
1219                if let Some(path) = item.cache_path.clone() {
1220                    resolved = Some((item, path));
1221                }
1222            }
1223            let Some((item, path)) = resolved else {
1224                eprintln!(
1225                    "warning: component {} is not cached; skipping embed",
1226                    comp.name
1227                );
1228                comp.bundled = false;
1229                comp.bundled_path = None;
1230                comp.wasm_sha256 = None;
1231                comp.resolved_digest = None;
1232                continue;
1233            };
1234            ResolvedLockItem {
1235                item,
1236                cache_path: path,
1237            }
1238        };
1239
1240        let cache_path = resolved.cache_path;
1241        let bytes = fs::read(&cache_path)
1242            .with_context(|| format!("failed to read cached component {}", cache_path.display()))?;
1243        let wasm_sha256 = format!("{:x}", Sha256::digest(&bytes));
1244        let logical_path = if is_tag {
1245            format!("blobs/sha256/{}.wasm", wasm_sha256)
1246        } else {
1247            format!("components/{}.wasm", comp.name)
1248        };
1249
1250        comp.bundled = true;
1251        comp.bundled_path = Some(logical_path.clone());
1252        comp.wasm_sha256 = Some(wasm_sha256.clone());
1253        if is_tag {
1254            comp.digest = format!("sha256:{wasm_sha256}");
1255            comp.resolved_digest = Some(resolved.item.digest.clone());
1256        } else {
1257            comp.resolved_digest = None;
1258        }
1259
1260        if seen_paths.insert(logical_path.clone()) {
1261            artifacts.push(LockComponentBinary {
1262                logical_path: logical_path.clone(),
1263                source: cache_path.clone(),
1264            });
1265        }
1266        if let Some(component_id) = comp.component_id.as_ref()
1267            && comp.bundled
1268        {
1269            let alias_path = format!("components/{}.wasm", component_id.as_str());
1270            if alias_path != logical_path && seen_paths.insert(alias_path.clone()) {
1271                artifacts.push(LockComponentBinary {
1272                    logical_path: alias_path,
1273                    source: cache_path.clone(),
1274                });
1275            }
1276        }
1277    }
1278
1279    Ok(artifacts)
1280}
1281
1282struct ResolvedLockItem {
1283    item: greentic_distributor_client::ResolvedArtifact,
1284    cache_path: PathBuf,
1285}
1286
1287struct MaterializedComponents {
1288    components: Vec<ComponentManifest>,
1289    manifest_files: Vec<ComponentManifestFile>,
1290    manifest_paths: Option<BTreeMap<String, String>>,
1291}
1292
1293fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1294    entries.push(SbomEntry {
1295        path: path.to_string(),
1296        size: bytes.len() as u64,
1297        hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1298        media_type: media_type.to_string(),
1299    });
1300}
1301
1302fn write_zip_entry(
1303    writer: &mut ZipWriter<std::fs::File>,
1304    logical_path: &str,
1305    bytes: &[u8],
1306    options: SimpleFileOptions,
1307) -> Result<()> {
1308    writer
1309        .start_file(logical_path, options)
1310        .with_context(|| format!("failed to start {}", logical_path))?;
1311    writer
1312        .write_all(bytes)
1313        .with_context(|| format!("failed to write {}", logical_path))?;
1314    Ok(())
1315}
1316
1317fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1318    if let Some(parent) = path.parent() {
1319        fs::create_dir_all(parent)
1320            .with_context(|| format!("failed to create directory {}", parent.display()))?;
1321    }
1322    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1323    Ok(())
1324}
1325
1326fn write_stub_wasm(path: &Path) -> Result<()> {
1327    const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1328    write_bytes(path, STUB)
1329}
1330
1331fn collect_component_manifest_files(
1332    components: &[ComponentBinary],
1333    extra: &[ComponentManifestFile],
1334) -> Vec<ComponentManifestFile> {
1335    let mut files: Vec<ComponentManifestFile> = components
1336        .iter()
1337        .map(|binary| ComponentManifestFile {
1338            component_id: binary.id.clone(),
1339            manifest_path: binary.manifest_path.clone(),
1340            manifest_bytes: binary.manifest_bytes.clone(),
1341            manifest_hash_sha256: binary.manifest_hash_sha256.clone(),
1342        })
1343        .collect();
1344    files.extend(extra.iter().cloned());
1345    files.sort_by(|a, b| a.component_id.cmp(&b.component_id));
1346    files.dedup_by(|a, b| a.component_id == b.component_id);
1347    files
1348}
1349
1350fn materialize_flow_components(
1351    pack_dir: &Path,
1352    flows: &[PackFlowEntry],
1353    pack_lock: &greentic_pack::pack_lock::PackLockV1,
1354    components: &[ComponentBinary],
1355    lock_components: &[LockComponentBinary],
1356    require_component_manifests: bool,
1357) -> Result<MaterializedComponents> {
1358    let referenced = collect_flow_component_ids(flows);
1359    if referenced.is_empty() {
1360        return Ok(MaterializedComponents {
1361            components: Vec::new(),
1362            manifest_files: Vec::new(),
1363            manifest_paths: None,
1364        });
1365    }
1366
1367    let mut existing = BTreeSet::new();
1368    for component in components {
1369        existing.insert(component.id.clone());
1370    }
1371
1372    let mut lock_by_id = BTreeMap::new();
1373    let mut lock_by_name = BTreeMap::new();
1374    for entry in &pack_lock.components {
1375        if let Some(component_id) = entry.component_id.as_ref() {
1376            lock_by_id.insert(component_id.as_str().to_string(), entry);
1377        }
1378        lock_by_name.insert(entry.name.clone(), entry);
1379    }
1380
1381    let mut bundle_sources = BTreeMap::new();
1382    for entry in lock_components {
1383        bundle_sources.insert(entry.logical_path.clone(), entry.source.clone());
1384    }
1385
1386    let mut materialized_components = Vec::new();
1387    let mut manifest_files = Vec::new();
1388    let mut manifest_paths: BTreeMap<String, String> = BTreeMap::new();
1389
1390    for component_id in referenced {
1391        if existing.contains(&component_id) {
1392            continue;
1393        }
1394
1395        let lock_entry = lock_by_id
1396            .get(&component_id)
1397            .copied()
1398            .or_else(|| lock_by_name.get(&component_id).copied());
1399        let Some(lock_entry) = lock_entry else {
1400            handle_missing_component_manifest(&component_id, None, require_component_manifests)?;
1401            continue;
1402        };
1403        if !lock_entry.bundled {
1404            if require_component_manifests {
1405                anyhow::bail!(
1406                    "component {} is not bundled; cannot materialize manifest without local artifacts",
1407                    lock_entry.name
1408                );
1409            }
1410            eprintln!(
1411                "warning: component {} is not bundled; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1412                lock_entry.name
1413            );
1414            continue;
1415        }
1416
1417        let bundled_source = lock_entry
1418            .bundled_path
1419            .as_deref()
1420            .and_then(|path| bundle_sources.get(path));
1421        let manifest = load_component_manifest_for_lock(pack_dir, lock_entry, bundled_source)?;
1422
1423        let Some(manifest) = manifest else {
1424            handle_missing_component_manifest(
1425                &component_id,
1426                Some(&lock_entry.name),
1427                require_component_manifests,
1428            )?;
1429            continue;
1430        };
1431
1432        if let Some(lock_id) = lock_entry.component_id.as_ref()
1433            && manifest.id.as_str() != lock_id.as_str()
1434        {
1435            anyhow::bail!(
1436                "component manifest id {} does not match pack.lock component_id {}",
1437                manifest.id.as_str(),
1438                lock_id.as_str()
1439            );
1440        }
1441
1442        let manifest_file = component_manifest_file_from_manifest(&manifest)?;
1443        manifest_paths.insert(
1444            manifest.id.as_str().to_string(),
1445            manifest_file.manifest_path.clone(),
1446        );
1447        manifest_paths.insert(lock_entry.name.clone(), manifest_file.manifest_path.clone());
1448
1449        materialized_components.push(manifest);
1450        manifest_files.push(manifest_file);
1451    }
1452
1453    let manifest_paths = if manifest_paths.is_empty() {
1454        None
1455    } else {
1456        Some(manifest_paths)
1457    };
1458
1459    Ok(MaterializedComponents {
1460        components: materialized_components,
1461        manifest_files,
1462        manifest_paths,
1463    })
1464}
1465
1466fn collect_flow_component_ids(flows: &[PackFlowEntry]) -> BTreeSet<String> {
1467    let mut ids = BTreeSet::new();
1468    for flow in flows {
1469        for node in flow.flow.nodes.values() {
1470            if node.component.pack_alias.is_some() {
1471                continue;
1472            }
1473            let id = node.component.id.as_str();
1474            if !id.is_empty() {
1475                ids.insert(id.to_string());
1476            }
1477        }
1478    }
1479    ids
1480}
1481
1482fn load_component_manifest_for_lock(
1483    pack_dir: &Path,
1484    lock_entry: &greentic_pack::pack_lock::LockedComponent,
1485    bundled_source: Option<&PathBuf>,
1486) -> Result<Option<ComponentManifest>> {
1487    let mut search_paths = Vec::new();
1488    if let Some(component_id) = lock_entry.component_id.as_ref() {
1489        let id = component_id.as_str();
1490        search_paths.extend(component_manifest_search_paths(pack_dir, id));
1491    }
1492    search_paths.extend(component_manifest_search_paths(pack_dir, &lock_entry.name));
1493    if let Some(source) = bundled_source
1494        && let Some(parent) = source.parent()
1495    {
1496        search_paths.push(parent.join("component.manifest.cbor"));
1497        search_paths.push(parent.join("component.manifest.json"));
1498    }
1499
1500    for path in search_paths {
1501        if path.exists() {
1502            return Ok(Some(load_component_manifest_from_file(&path)?));
1503        }
1504    }
1505
1506    Ok(None)
1507}
1508
1509fn component_manifest_search_paths(pack_dir: &Path, name: &str) -> Vec<PathBuf> {
1510    vec![
1511        pack_dir
1512            .join("components")
1513            .join(format!("{name}.manifest.cbor")),
1514        pack_dir
1515            .join("components")
1516            .join(format!("{name}.manifest.json")),
1517        pack_dir
1518            .join("components")
1519            .join(name)
1520            .join("component.manifest.cbor"),
1521        pack_dir
1522            .join("components")
1523            .join(name)
1524            .join("component.manifest.json"),
1525    ]
1526}
1527
1528fn load_component_manifest_from_file(path: &Path) -> Result<ComponentManifest> {
1529    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1530    if path
1531        .extension()
1532        .and_then(|ext| ext.to_str())
1533        .is_some_and(|ext| ext.eq_ignore_ascii_case("cbor"))
1534    {
1535        let manifest = serde_cbor::from_slice(&bytes)
1536            .with_context(|| format!("{} is not valid CBOR", path.display()))?;
1537        return Ok(manifest);
1538    }
1539
1540    let manifest = serde_json::from_slice(&bytes)
1541        .with_context(|| format!("{} is not valid JSON", path.display()))?;
1542    Ok(manifest)
1543}
1544
1545fn component_manifest_file_from_manifest(
1546    manifest: &ComponentManifest,
1547) -> Result<ComponentManifestFile> {
1548    let manifest_bytes =
1549        serde_cbor::to_vec(manifest).context("encode component manifest to cbor")?;
1550    let mut sha = Sha256::new();
1551    sha.update(&manifest_bytes);
1552    let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
1553    let manifest_path = format!("components/{}.manifest.cbor", manifest.id.as_str());
1554
1555    Ok(ComponentManifestFile {
1556        component_id: manifest.id.as_str().to_string(),
1557        manifest_path,
1558        manifest_bytes,
1559        manifest_hash_sha256,
1560    })
1561}
1562
1563fn handle_missing_component_manifest(
1564    component_id: &str,
1565    component_name: Option<&str>,
1566    require_component_manifests: bool,
1567) -> Result<()> {
1568    let label = component_name.unwrap_or(component_id);
1569    if require_component_manifests {
1570        anyhow::bail!(
1571            "component manifest metadata missing for {} (supply component.manifest.json or use --require-component-manifests=false)",
1572            label
1573        );
1574    }
1575    eprintln!(
1576        "warning: component manifest metadata missing for {}; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1577        label
1578    );
1579    Ok(())
1580}
1581
1582fn aggregate_secret_requirements(
1583    components: &[ComponentConfig],
1584    override_path: Option<&Path>,
1585    default_scope: Option<&str>,
1586) -> Result<Vec<SecretRequirement>> {
1587    let default_scope = default_scope.map(parse_default_scope).transpose()?;
1588    let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1589
1590    let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1591        let mut req = req.clone();
1592        if req.scope.is_none() {
1593            if let Some(scope) = default_scope.clone() {
1594                req.scope = Some(scope);
1595                tracing::warn!(
1596                    key = %secret_key_string(&req),
1597                    source,
1598                    "secret requirement missing scope; applying default scope"
1599                );
1600            } else {
1601                anyhow::bail!(
1602                    "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1603                    secret_key_string(&req),
1604                    source
1605                );
1606            }
1607        }
1608        let scope = req.scope.as_ref().expect("scope present");
1609        let fmt = fmt_key(&req);
1610        let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1611        if let Some(existing) = merged.get_mut(&key_tuple) {
1612            merge_requirement(existing, &req);
1613        } else {
1614            merged.insert(key_tuple, req);
1615        }
1616        Ok(())
1617    };
1618
1619    for component in components {
1620        if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1621            for req in &secret_caps.required {
1622                process_req(req, &component.id)?;
1623            }
1624        }
1625    }
1626
1627    if let Some(path) = override_path {
1628        let contents = fs::read_to_string(path)
1629            .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1630        let value: serde_json::Value = if path
1631            .extension()
1632            .and_then(|ext| ext.to_str())
1633            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1634            .unwrap_or(false)
1635        {
1636            let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1637                .with_context(|| format!("{} is not valid YAML", path.display()))?;
1638            serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1639        } else {
1640            serde_json::from_str(&contents)
1641                .with_context(|| format!("{} is not valid JSON", path.display()))?
1642        };
1643
1644        let overrides: Vec<SecretRequirement> =
1645            serde_json::from_value(value).with_context(|| {
1646                format!(
1647                    "{} must be an array of secret requirements (migration bridge)",
1648                    path.display()
1649                )
1650            })?;
1651        for req in &overrides {
1652            process_req(req, &format!("override:{}", path.display()))?;
1653        }
1654    }
1655
1656    let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1657    out.sort_by(|a, b| {
1658        let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1659        let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1660        (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1661            b_scope,
1662            secret_key_string(b),
1663            fmt_key(b),
1664        ))
1665    });
1666    Ok(out)
1667}
1668
1669fn fmt_key(req: &SecretRequirement) -> String {
1670    req.format
1671        .as_ref()
1672        .map(|f| format!("{:?}", f))
1673        .unwrap_or_else(|| "unspecified".to_string())
1674}
1675
1676fn scope_key(scope: &SecretScope) -> String {
1677    format!(
1678        "{}/{}/{}",
1679        &scope.env,
1680        &scope.tenant,
1681        scope
1682            .team
1683            .as_deref()
1684            .map(|t| t.to_string())
1685            .unwrap_or_else(|| "_".to_string())
1686    )
1687}
1688
1689fn secret_key_string(req: &SecretRequirement) -> String {
1690    let key: String = req.key.clone().into();
1691    key
1692}
1693
1694fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1695    if base.description.is_none() {
1696        base.description = incoming.description.clone();
1697    }
1698    if let Some(schema) = &incoming.schema {
1699        if base.schema.is_none() {
1700            base.schema = Some(schema.clone());
1701        } else if base.schema.as_ref() != Some(schema) {
1702            tracing::warn!(
1703                key = %secret_key_string(base),
1704                "conflicting secret schema encountered; keeping first"
1705            );
1706        }
1707    }
1708
1709    if !incoming.examples.is_empty() {
1710        for example in &incoming.examples {
1711            if !base.examples.contains(example) {
1712                base.examples.push(example.clone());
1713            }
1714        }
1715    }
1716
1717    base.required = base.required || incoming.required;
1718}
1719
1720fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1721    let parts: Vec<_> = raw.split('/').collect();
1722    if parts.len() < 2 || parts.len() > 3 {
1723        anyhow::bail!(
1724            "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1725            raw
1726        );
1727    }
1728    Ok(SecretScope {
1729        env: parts[0].to_string(),
1730        tenant: parts[1].to_string(),
1731        team: parts.get(2).map(|s| s.to_string()),
1732    })
1733}
1734
1735fn write_secret_requirements_file(
1736    pack_root: &Path,
1737    requirements: &[SecretRequirement],
1738    logical_name: &str,
1739) -> Result<PathBuf> {
1740    let path = pack_root.join(".packc").join(logical_name);
1741    if let Some(parent) = path.parent() {
1742        fs::create_dir_all(parent)
1743            .with_context(|| format!("failed to create {}", parent.display()))?;
1744    }
1745    let data = serde_json::to_vec_pretty(&requirements)
1746        .context("failed to serialise secret requirements")?;
1747    fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1748    Ok(path)
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754    use crate::config::BootstrapConfig;
1755    use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1756    use greentic_types::flow::FlowKind;
1757    use serde_json::json;
1758    use sha2::{Digest, Sha256};
1759    use std::fs::File;
1760    use std::io::Read;
1761    use std::{fs, path::PathBuf};
1762    use tempfile::tempdir;
1763    use zip::ZipArchive;
1764
1765    #[test]
1766    fn map_kind_accepts_known_values() {
1767        assert!(matches!(
1768            map_kind("application").unwrap(),
1769            PackKind::Application
1770        ));
1771        assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1772        assert!(matches!(
1773            map_kind("infrastructure").unwrap(),
1774            PackKind::Infrastructure
1775        ));
1776        assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1777        assert!(map_kind("unknown").is_err());
1778    }
1779
1780    #[test]
1781    fn collect_assets_preserves_relative_paths() {
1782        let root = PathBuf::from("/packs/demo");
1783        let assets = vec![AssetConfig {
1784            path: root.join("assets").join("foo.txt"),
1785        }];
1786        let collected = collect_assets(&assets, &root).expect("collect assets");
1787        assert_eq!(collected[0].logical_path, "assets/foo.txt");
1788    }
1789
1790    #[test]
1791    fn build_bootstrap_requires_known_references() {
1792        let config = pack_config_with_bootstrap(BootstrapConfig {
1793            install_flow: Some("flow.a".to_string()),
1794            upgrade_flow: None,
1795            installer_component: Some("component.a".to_string()),
1796        });
1797        let flows = vec![flow_entry("flow.a")];
1798        let components = vec![minimal_component_manifest("component.a")];
1799
1800        let bootstrap = build_bootstrap(&config, &flows, &components)
1801            .expect("bootstrap populated")
1802            .expect("bootstrap present");
1803
1804        assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
1805        assert_eq!(bootstrap.upgrade_flow, None);
1806        assert_eq!(
1807            bootstrap.installer_component.as_deref(),
1808            Some("component.a")
1809        );
1810    }
1811
1812    #[test]
1813    fn build_bootstrap_rejects_unknown_flow() {
1814        let config = pack_config_with_bootstrap(BootstrapConfig {
1815            install_flow: Some("missing".to_string()),
1816            upgrade_flow: None,
1817            installer_component: Some("component.a".to_string()),
1818        });
1819        let flows = vec![flow_entry("flow.a")];
1820        let components = vec![minimal_component_manifest("component.a")];
1821
1822        let err = build_bootstrap(&config, &flows, &components).unwrap_err();
1823        assert!(
1824            err.to_string()
1825                .contains("bootstrap.install_flow references unknown flow"),
1826            "unexpected error: {err}"
1827        );
1828    }
1829
1830    #[test]
1831    fn component_manifest_without_dev_flows_defaults_to_empty() {
1832        let manifest: ComponentManifest = serde_json::from_value(json!({
1833            "id": "component.dev",
1834            "version": "1.0.0",
1835            "supports": ["messaging"],
1836            "world": "greentic:demo@1.0.0",
1837            "profiles": { "default": "default", "supported": ["default"] },
1838            "capabilities": { "wasi": {}, "host": {} },
1839            "operations": [],
1840            "resources": {}
1841        }))
1842        .expect("manifest without dev_flows");
1843
1844        assert!(manifest.dev_flows.is_empty());
1845
1846        let pack_manifest = pack_manifest_with_component(manifest.clone());
1847        let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1848        let decoded: PackManifest =
1849            greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
1850        let stored = decoded
1851            .components
1852            .iter()
1853            .find(|item| item.id == manifest.id)
1854            .expect("component present");
1855        assert!(stored.dev_flows.is_empty());
1856    }
1857
1858    #[test]
1859    fn dev_flows_round_trip_in_manifest_and_gtpack() {
1860        let component = manifest_with_dev_flow();
1861        let pack_manifest = pack_manifest_with_component(component.clone());
1862        let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1863
1864        let decoded: PackManifest =
1865            greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
1866        let decoded_component = decoded
1867            .components
1868            .iter()
1869            .find(|item| item.id == component.id)
1870            .expect("component present");
1871        assert_eq!(decoded_component.dev_flows, component.dev_flows);
1872
1873        let temp = tempdir().expect("temp dir");
1874        let wasm_path = temp.path().join("component.wasm");
1875        write_stub_wasm(&wasm_path).expect("write stub wasm");
1876
1877        let build = BuildProducts {
1878            manifest: pack_manifest,
1879            components: vec![ComponentBinary {
1880                id: component.id.to_string(),
1881                source: wasm_path,
1882                manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
1883                manifest_path: format!("components/{}.manifest.cbor", component.id),
1884                manifest_hash_sha256: {
1885                    let mut sha = Sha256::new();
1886                    sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
1887                    format!("sha256:{:x}", sha.finalize())
1888                },
1889            }],
1890            lock_components: Vec::new(),
1891            component_manifest_files: Vec::new(),
1892            flow_files: Vec::new(),
1893            assets: Vec::new(),
1894        };
1895
1896        let out = temp.path().join("demo.gtpack");
1897        package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
1898
1899        let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
1900            .expect("read gtpack archive");
1901        let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
1902        let mut stored = Vec::new();
1903        manifest_entry
1904            .read_to_end(&mut stored)
1905            .expect("read manifest");
1906        let decoded: PackManifest =
1907            greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
1908
1909        let stored_component = decoded
1910            .components
1911            .iter()
1912            .find(|item| item.id == component.id)
1913            .expect("component preserved");
1914        assert_eq!(stored_component.dev_flows, component.dev_flows);
1915    }
1916
1917    #[test]
1918    fn component_sources_extension_respects_bundle() {
1919        let lock_tag = PackLockV1::new(vec![LockedComponent {
1920            name: "demo.tagged".into(),
1921            r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
1922            digest: "sha256:deadbeef".into(),
1923            component_id: None,
1924            bundled: true,
1925            bundled_path: Some("blobs/sha256/deadbeef.wasm".into()),
1926            wasm_sha256: Some("deadbeef".repeat(8)),
1927            resolved_digest: Some("sha256:deadbeef".into()),
1928        }]);
1929
1930        let ext_none = merge_component_sources_extension(None, &lock_tag, BundleMode::None, None)
1931            .expect("ext");
1932        let value = match ext_none
1933            .unwrap()
1934            .get(EXT_COMPONENT_SOURCES_V1)
1935            .and_then(|e| e.inline.as_ref())
1936        {
1937            Some(ExtensionInline::Other(v)) => v.clone(),
1938            _ => panic!("missing inline"),
1939        };
1940        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1941        assert!(matches!(
1942            decoded.components[0].artifact,
1943            ArtifactLocationV1::Inline { .. }
1944        ));
1945
1946        let lock_digest = PackLockV1::new(vec![LockedComponent {
1947            name: "demo.component".into(),
1948            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
1949            digest: "sha256:deadbeef".into(),
1950            component_id: None,
1951            bundled: false,
1952            bundled_path: None,
1953            wasm_sha256: None,
1954            resolved_digest: None,
1955        }]);
1956
1957        let ext_none =
1958            merge_component_sources_extension(None, &lock_digest, BundleMode::None, None)
1959                .expect("ext");
1960        let value = match ext_none
1961            .unwrap()
1962            .get(EXT_COMPONENT_SOURCES_V1)
1963            .and_then(|e| e.inline.as_ref())
1964        {
1965            Some(ExtensionInline::Other(v)) => v.clone(),
1966            _ => panic!("missing inline"),
1967        };
1968        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1969        assert!(matches!(
1970            decoded.components[0].artifact,
1971            ArtifactLocationV1::Remote
1972        ));
1973
1974        let lock_digest_bundled = PackLockV1::new(vec![LockedComponent {
1975            name: "demo.component".into(),
1976            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
1977            digest: "sha256:deadbeef".into(),
1978            component_id: None,
1979            bundled: true,
1980            bundled_path: Some("components/demo.component.wasm".into()),
1981            wasm_sha256: Some("deadbeef".repeat(8)),
1982            resolved_digest: None,
1983        }]);
1984
1985        let ext_cache =
1986            merge_component_sources_extension(None, &lock_digest_bundled, BundleMode::Cache, None)
1987                .expect("ext");
1988        let value = match ext_cache
1989            .unwrap()
1990            .get(EXT_COMPONENT_SOURCES_V1)
1991            .and_then(|e| e.inline.as_ref())
1992        {
1993            Some(ExtensionInline::Other(v)) => v.clone(),
1994            _ => panic!("missing inline"),
1995        };
1996        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1997        assert!(matches!(
1998            decoded.components[0].artifact,
1999            ArtifactLocationV1::Inline { .. }
2000        ));
2001    }
2002
2003    #[test]
2004    fn component_sources_extension_skips_file_refs() {
2005        let lock = PackLockV1::new(vec![LockedComponent {
2006            name: "local.component".into(),
2007            r#ref: "file:///tmp/component.wasm".into(),
2008            digest: "sha256:deadbeef".into(),
2009            component_id: None,
2010            bundled: false,
2011            bundled_path: None,
2012            wasm_sha256: None,
2013            resolved_digest: None,
2014        }]);
2015
2016        let ext_none =
2017            merge_component_sources_extension(None, &lock, BundleMode::Cache, None).expect("ext");
2018        assert!(ext_none.is_none(), "file refs should be omitted");
2019
2020        let lock = PackLockV1::new(vec![
2021            LockedComponent {
2022                name: "local.component".into(),
2023                r#ref: "file:///tmp/component.wasm".into(),
2024                digest: "sha256:deadbeef".into(),
2025                component_id: None,
2026                bundled: false,
2027                bundled_path: None,
2028                wasm_sha256: None,
2029                resolved_digest: None,
2030            },
2031            LockedComponent {
2032                name: "remote.component".into(),
2033                r#ref: "oci://ghcr.io/demo/component:2.0.0".into(),
2034                digest: "sha256:cafebabe".into(),
2035                component_id: None,
2036                bundled: false,
2037                bundled_path: None,
2038                wasm_sha256: None,
2039                resolved_digest: None,
2040            },
2041        ]);
2042
2043        let ext_some =
2044            merge_component_sources_extension(None, &lock, BundleMode::None, None).expect("ext");
2045        let value = match ext_some
2046            .unwrap()
2047            .get(EXT_COMPONENT_SOURCES_V1)
2048            .and_then(|e| e.inline.as_ref())
2049        {
2050            Some(ExtensionInline::Other(v)) => v.clone(),
2051            _ => panic!("missing inline"),
2052        };
2053        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2054        assert_eq!(decoded.components.len(), 1);
2055        assert!(matches!(
2056            decoded.components[0].source,
2057            ComponentSourceRef::Oci(_)
2058        ));
2059    }
2060
2061    #[test]
2062    fn build_embeds_lock_components_from_cache() {
2063        let rt = tokio::runtime::Runtime::new().expect("runtime");
2064        rt.block_on(async {
2065            let temp = tempdir().expect("temp dir");
2066            let pack_dir = temp.path().join("pack");
2067            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2068            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2069
2070            let wasm_path = pack_dir.join("components/dummy.wasm");
2071            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2072                .expect("write wasm");
2073
2074            let flow_path = pack_dir.join("flows/main.ygtc");
2075            fs::write(
2076                &flow_path,
2077                r#"id: main
2078type: messaging
2079start: call
2080nodes:
2081  call:
2082    handle_message:
2083      text: "hi"
2084    routing: out
2085"#,
2086            )
2087            .expect("write flow");
2088
2089            let cache_dir = temp.path().join("cache");
2090            let cached_bytes = b"cached-component";
2091            let digest = format!("sha256:{:x}", Sha256::digest(cached_bytes));
2092            let cache_path = cache_dir
2093                .join(digest.trim_start_matches("sha256:"))
2094                .join("component.wasm");
2095            fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache dir");
2096            fs::write(&cache_path, cached_bytes).expect("write cached");
2097
2098            let summary = serde_json::json!({
2099                "schema_version": 1,
2100                "flow": "main.ygtc",
2101                "nodes": {
2102                    "call": {
2103                        "component_id": "dummy.component",
2104                        "source": {
2105                            "kind": "oci",
2106                            "ref": format!("oci://ghcr.io/demo/component@{digest}")
2107                        },
2108                        "digest": digest
2109                    }
2110                }
2111            });
2112            fs::write(
2113                flow_path.with_extension("ygtc.resolve.summary.json"),
2114                serde_json::to_vec_pretty(&summary).expect("summary json"),
2115            )
2116            .expect("write summary");
2117
2118            let pack_yaml = r#"pack_id: demo.lock-bundle
2119version: 0.1.0
2120kind: application
2121publisher: Test
2122components:
2123  - id: dummy.component
2124    version: "0.1.0"
2125    world: "greentic:component/component@0.5.0"
2126    supports: ["messaging"]
2127    profiles:
2128      default: "stateless"
2129      supported: ["stateless"]
2130    capabilities:
2131      wasi: {}
2132      host: {}
2133    operations:
2134      - name: "handle_message"
2135        input_schema: {}
2136        output_schema: {}
2137    wasm: "components/dummy.wasm"
2138flows:
2139  - id: main
2140    file: flows/main.ygtc
2141    tags: [default]
2142    entrypoints: [main]
2143"#;
2144            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2145
2146            let runtime = crate::runtime::resolve_runtime(
2147                Some(pack_dir.as_path()),
2148                Some(cache_dir.as_path()),
2149                true,
2150                None,
2151            )
2152            .expect("runtime");
2153
2154            let opts = BuildOptions {
2155                pack_dir: pack_dir.clone(),
2156                component_out: None,
2157                manifest_out: pack_dir.join("dist/manifest.cbor"),
2158                sbom_out: None,
2159                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2160                lock_path: pack_dir.join("pack.lock.json"),
2161                bundle: BundleMode::Cache,
2162                dry_run: false,
2163                secrets_req: None,
2164                default_secret_scope: None,
2165                allow_oci_tags: false,
2166                require_component_manifests: false,
2167                runtime,
2168                skip_update: false,
2169            };
2170
2171            run(&opts).await.expect("build");
2172
2173            let gtpack_path = opts.gtpack_out.expect("gtpack path");
2174            let mut archive = ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
2175                .expect("read gtpack");
2176            assert!(
2177                archive.by_name("components/main___call.wasm").is_ok(),
2178                "missing lock component artifact in gtpack"
2179            );
2180        });
2181    }
2182
2183    #[test]
2184    #[ignore = "requires network access to fetch OCI component"]
2185    fn build_fetches_and_embeds_lock_components_online() {
2186        if std::env::var("GREENTIC_PACK_ONLINE").is_err() {
2187            return;
2188        }
2189        let rt = tokio::runtime::Runtime::new().expect("runtime");
2190        rt.block_on(async {
2191            let temp = tempdir().expect("temp dir");
2192            let pack_dir = temp.path().join("pack");
2193            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2194            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2195
2196            let wasm_path = pack_dir.join("components/dummy.wasm");
2197            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2198                .expect("write wasm");
2199
2200            let flow_path = pack_dir.join("flows/main.ygtc");
2201            fs::write(
2202                &flow_path,
2203                r#"id: main
2204type: messaging
2205start: call
2206nodes:
2207  call:
2208    handle_message:
2209      text: "hi"
2210    routing: out
2211"#,
2212            )
2213            .expect("write flow");
2214
2215            let digest =
2216                "sha256:0904bee6ecd737506265e3f38f3e4fe6b185c20fd1b0e7c06ce03cdeedc00340";
2217            let summary = serde_json::json!({
2218                "schema_version": 1,
2219                "flow": "main.ygtc",
2220                "nodes": {
2221                    "call": {
2222                        "component_id": "dummy.component",
2223                        "source": {
2224                            "kind": "oci",
2225                            "ref": format!("oci://ghcr.io/greentic-ai/components/templates@{digest}")
2226                        },
2227                        "digest": digest
2228                    }
2229                }
2230            });
2231            fs::write(
2232                flow_path.with_extension("ygtc.resolve.summary.json"),
2233                serde_json::to_vec_pretty(&summary).expect("summary json"),
2234            )
2235            .expect("write summary");
2236
2237            let pack_yaml = r#"pack_id: demo.lock-online
2238version: 0.1.0
2239kind: application
2240publisher: Test
2241components:
2242  - id: dummy.component
2243    version: "0.1.0"
2244    world: "greentic:component/component@0.5.0"
2245    supports: ["messaging"]
2246    profiles:
2247      default: "stateless"
2248      supported: ["stateless"]
2249    capabilities:
2250      wasi: {}
2251      host: {}
2252    operations:
2253      - name: "handle_message"
2254        input_schema: {}
2255        output_schema: {}
2256    wasm: "components/dummy.wasm"
2257flows:
2258  - id: main
2259    file: flows/main.ygtc
2260    tags: [default]
2261    entrypoints: [main]
2262"#;
2263            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2264
2265            let cache_dir = temp.path().join("cache");
2266            let runtime = crate::runtime::resolve_runtime(
2267                Some(pack_dir.as_path()),
2268                Some(cache_dir.as_path()),
2269                false,
2270                None,
2271            )
2272            .expect("runtime");
2273
2274            let opts = BuildOptions {
2275                pack_dir: pack_dir.clone(),
2276                component_out: None,
2277                manifest_out: pack_dir.join("dist/manifest.cbor"),
2278                sbom_out: None,
2279                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2280                lock_path: pack_dir.join("pack.lock.json"),
2281                bundle: BundleMode::Cache,
2282                dry_run: false,
2283                secrets_req: None,
2284                default_secret_scope: None,
2285                allow_oci_tags: false,
2286                require_component_manifests: false,
2287                runtime,
2288                skip_update: false,
2289            };
2290
2291            run(&opts).await.expect("build");
2292
2293            let gtpack_path = opts.gtpack_out.expect("gtpack path");
2294            let mut archive =
2295                ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
2296                    .expect("read gtpack");
2297            assert!(
2298                archive.by_name("components/main___call.wasm").is_ok(),
2299                "missing lock component artifact in gtpack"
2300            );
2301        });
2302    }
2303
2304    #[test]
2305    fn aggregate_secret_requirements_dedupes_and_sorts() {
2306        let component: ComponentConfig = serde_json::from_value(json!({
2307            "id": "component.a",
2308            "version": "1.0.0",
2309            "world": "greentic:demo@1.0.0",
2310            "supports": [],
2311            "profiles": { "default": "default", "supported": ["default"] },
2312            "capabilities": {
2313                "wasi": {},
2314                "host": {
2315                    "secrets": {
2316                        "required": [
2317                    {
2318                        "key": "db/password",
2319                        "required": true,
2320                        "scope": { "env": "dev", "tenant": "t1" },
2321                        "format": "text",
2322                        "description": "primary"
2323                    }
2324                ]
2325            }
2326        }
2327            },
2328            "wasm": "component.wasm",
2329            "operations": [],
2330            "resources": {}
2331        }))
2332        .expect("component config");
2333
2334        let dupe: ComponentConfig = serde_json::from_value(json!({
2335            "id": "component.b",
2336            "version": "1.0.0",
2337            "world": "greentic:demo@1.0.0",
2338            "supports": [],
2339            "profiles": { "default": "default", "supported": ["default"] },
2340            "capabilities": {
2341                "wasi": {},
2342                "host": {
2343                    "secrets": {
2344                        "required": [
2345                            {
2346                        "key": "db/password",
2347                        "required": true,
2348                        "scope": { "env": "dev", "tenant": "t1" },
2349                        "format": "text",
2350                        "description": "secondary",
2351                        "examples": ["example"]
2352                    }
2353                ]
2354            }
2355                }
2356            },
2357            "wasm": "component.wasm",
2358            "operations": [],
2359            "resources": {}
2360        }))
2361        .expect("component config");
2362
2363        let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
2364            .expect("aggregate secrets");
2365        assert_eq!(reqs.len(), 1);
2366        let req = &reqs[0];
2367        assert_eq!(req.description.as_deref(), Some("primary"));
2368        assert!(req.examples.contains(&"example".to_string()));
2369    }
2370
2371    fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
2372        PackConfig {
2373            pack_id: "demo.pack".to_string(),
2374            version: "1.0.0".to_string(),
2375            kind: "application".to_string(),
2376            publisher: "demo".to_string(),
2377            bootstrap: Some(bootstrap),
2378            components: Vec::new(),
2379            dependencies: Vec::new(),
2380            flows: Vec::new(),
2381            assets: Vec::new(),
2382            extensions: None,
2383        }
2384    }
2385
2386    fn flow_entry(id: &str) -> PackFlowEntry {
2387        let flow: Flow = serde_json::from_value(json!({
2388            "schema_version": "flow/v1",
2389            "id": id,
2390            "kind": "messaging"
2391        }))
2392        .expect("flow json");
2393
2394        PackFlowEntry {
2395            id: FlowId::new(id).expect("flow id"),
2396            kind: FlowKind::Messaging,
2397            flow,
2398            tags: Vec::new(),
2399            entrypoints: Vec::new(),
2400        }
2401    }
2402
2403    fn minimal_component_manifest(id: &str) -> ComponentManifest {
2404        serde_json::from_value(json!({
2405            "id": id,
2406            "version": "1.0.0",
2407            "supports": [],
2408            "world": "greentic:demo@1.0.0",
2409            "profiles": { "default": "default", "supported": ["default"] },
2410            "capabilities": { "wasi": {}, "host": {} },
2411            "operations": [],
2412            "resources": {}
2413        }))
2414        .expect("component manifest")
2415    }
2416
2417    fn manifest_with_dev_flow() -> ComponentManifest {
2418        serde_json::from_str(include_str!(
2419            "../tests/fixtures/component_manifest_with_dev_flows.json"
2420        ))
2421        .expect("fixture manifest")
2422    }
2423
2424    fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
2425        let flow = serde_json::from_value(json!({
2426            "schema_version": "flow/v1",
2427            "id": "flow.dev",
2428            "kind": "messaging"
2429        }))
2430        .expect("flow json");
2431
2432        PackManifest {
2433            schema_version: "pack-v1".to_string(),
2434            pack_id: PackId::new("demo.pack").expect("pack id"),
2435            version: Version::parse("1.0.0").expect("version"),
2436            kind: PackKind::Application,
2437            publisher: "demo".to_string(),
2438            components: vec![component],
2439            flows: vec![PackFlowEntry {
2440                id: FlowId::new("flow.dev").expect("flow id"),
2441                kind: FlowKind::Messaging,
2442                flow,
2443                tags: Vec::new(),
2444                entrypoints: Vec::new(),
2445            }],
2446            dependencies: Vec::new(),
2447            capabilities: Vec::new(),
2448            secret_requirements: Vec::new(),
2449            signatures: PackSignatures::default(),
2450            bootstrap: None,
2451            extensions: None,
2452        }
2453    }
2454}