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