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