Skip to main content

packc/
build.rs

1use crate::cli::resolve::{self, ResolveArgs};
2use crate::config::{
3    AssetConfig, ComponentConfig, ComponentOperationConfig, FlowConfig, PackConfig,
4};
5use crate::extensions::validate_components_extension;
6use crate::flow_resolve::load_flow_resolve_summary;
7use crate::runtime::{NetworkPolicy, RuntimeContext};
8use anyhow::{Context, Result, anyhow};
9use greentic_distributor_client::{DistClient, DistOptions};
10use greentic_flow::add_step::normalize::normalize_node_map;
11use greentic_flow::compile_ygtc_file;
12use greentic_flow::loader::load_ygtc_from_path;
13use greentic_pack::builder::SbomEntry;
14use greentic_pack::pack_lock::read_pack_lock;
15use greentic_types::component_source::ComponentSourceRef;
16use greentic_types::flow_resolve_summary::FlowResolveSummaryV1;
17use greentic_types::pack::extensions::component_manifests::{
18    ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
19    ManifestEncoding,
20};
21use greentic_types::pack::extensions::component_sources::{
22    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
23    ResolvedComponentV1,
24};
25use greentic_types::{
26    BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
27    ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
28    PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
29    encode_pack_manifest,
30};
31use semver::Version;
32use serde::Serialize;
33use serde_cbor;
34use serde_yaml_bw::Value as YamlValue;
35use sha2::{Digest, Sha256};
36use std::collections::{BTreeMap, BTreeSet};
37use std::fs;
38use std::io::Write;
39use std::path::{Path, PathBuf};
40use std::str::FromStr;
41use tracing::info;
42use zip::write::SimpleFileOptions;
43use zip::{CompressionMethod, ZipWriter};
44
45const SBOM_FORMAT: &str = "greentic-sbom-v1";
46
47#[derive(Serialize)]
48struct SbomDocument {
49    format: String,
50    files: Vec<SbomEntry>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
54pub enum BundleMode {
55    Cache,
56    None,
57}
58
59#[derive(Clone)]
60pub struct BuildOptions {
61    pub pack_dir: PathBuf,
62    pub component_out: Option<PathBuf>,
63    pub manifest_out: PathBuf,
64    pub sbom_out: Option<PathBuf>,
65    pub gtpack_out: Option<PathBuf>,
66    pub lock_path: PathBuf,
67    pub bundle: BundleMode,
68    pub dry_run: bool,
69    pub secrets_req: Option<PathBuf>,
70    pub default_secret_scope: Option<String>,
71    pub allow_oci_tags: bool,
72    pub 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);
1163        if is_tag {
1164            comp.resolved_digest = Some(resolved.item.digest.clone());
1165        } else {
1166            comp.resolved_digest = None;
1167        }
1168
1169        artifacts.push(LockComponentBinary {
1170            logical_path,
1171            source: cache_path,
1172        });
1173    }
1174
1175    Ok(artifacts)
1176}
1177
1178struct ResolvedLockItem {
1179    item: greentic_distributor_client::ResolvedArtifact,
1180    cache_path: PathBuf,
1181}
1182
1183fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1184    entries.push(SbomEntry {
1185        path: path.to_string(),
1186        size: bytes.len() as u64,
1187        hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1188        media_type: media_type.to_string(),
1189    });
1190}
1191
1192fn write_zip_entry(
1193    writer: &mut ZipWriter<std::fs::File>,
1194    logical_path: &str,
1195    bytes: &[u8],
1196    options: SimpleFileOptions,
1197) -> Result<()> {
1198    writer
1199        .start_file(logical_path, options)
1200        .with_context(|| format!("failed to start {}", logical_path))?;
1201    writer
1202        .write_all(bytes)
1203        .with_context(|| format!("failed to write {}", logical_path))?;
1204    Ok(())
1205}
1206
1207fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1208    if let Some(parent) = path.parent() {
1209        fs::create_dir_all(parent)
1210            .with_context(|| format!("failed to create directory {}", parent.display()))?;
1211    }
1212    fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1213    Ok(())
1214}
1215
1216fn write_stub_wasm(path: &Path) -> Result<()> {
1217    const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1218    write_bytes(path, STUB)
1219}
1220
1221fn aggregate_secret_requirements(
1222    components: &[ComponentConfig],
1223    override_path: Option<&Path>,
1224    default_scope: Option<&str>,
1225) -> Result<Vec<SecretRequirement>> {
1226    let default_scope = default_scope.map(parse_default_scope).transpose()?;
1227    let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1228
1229    let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1230        let mut req = req.clone();
1231        if req.scope.is_none() {
1232            if let Some(scope) = default_scope.clone() {
1233                req.scope = Some(scope);
1234                tracing::warn!(
1235                    key = %secret_key_string(&req),
1236                    source,
1237                    "secret requirement missing scope; applying default scope"
1238                );
1239            } else {
1240                anyhow::bail!(
1241                    "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1242                    secret_key_string(&req),
1243                    source
1244                );
1245            }
1246        }
1247        let scope = req.scope.as_ref().expect("scope present");
1248        let fmt = fmt_key(&req);
1249        let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1250        if let Some(existing) = merged.get_mut(&key_tuple) {
1251            merge_requirement(existing, &req);
1252        } else {
1253            merged.insert(key_tuple, req);
1254        }
1255        Ok(())
1256    };
1257
1258    for component in components {
1259        if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1260            for req in &secret_caps.required {
1261                process_req(req, &component.id)?;
1262            }
1263        }
1264    }
1265
1266    if let Some(path) = override_path {
1267        let contents = fs::read_to_string(path)
1268            .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1269        let value: serde_json::Value = if path
1270            .extension()
1271            .and_then(|ext| ext.to_str())
1272            .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1273            .unwrap_or(false)
1274        {
1275            let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1276                .with_context(|| format!("{} is not valid YAML", path.display()))?;
1277            serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1278        } else {
1279            serde_json::from_str(&contents)
1280                .with_context(|| format!("{} is not valid JSON", path.display()))?
1281        };
1282
1283        let overrides: Vec<SecretRequirement> =
1284            serde_json::from_value(value).with_context(|| {
1285                format!(
1286                    "{} must be an array of secret requirements (migration bridge)",
1287                    path.display()
1288                )
1289            })?;
1290        for req in &overrides {
1291            process_req(req, &format!("override:{}", path.display()))?;
1292        }
1293    }
1294
1295    let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1296    out.sort_by(|a, b| {
1297        let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1298        let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1299        (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1300            b_scope,
1301            secret_key_string(b),
1302            fmt_key(b),
1303        ))
1304    });
1305    Ok(out)
1306}
1307
1308fn fmt_key(req: &SecretRequirement) -> String {
1309    req.format
1310        .as_ref()
1311        .map(|f| format!("{:?}", f))
1312        .unwrap_or_else(|| "unspecified".to_string())
1313}
1314
1315fn scope_key(scope: &SecretScope) -> String {
1316    format!(
1317        "{}/{}/{}",
1318        &scope.env,
1319        &scope.tenant,
1320        scope
1321            .team
1322            .as_deref()
1323            .map(|t| t.to_string())
1324            .unwrap_or_else(|| "_".to_string())
1325    )
1326}
1327
1328fn secret_key_string(req: &SecretRequirement) -> String {
1329    let key: String = req.key.clone().into();
1330    key
1331}
1332
1333fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1334    if base.description.is_none() {
1335        base.description = incoming.description.clone();
1336    }
1337    if let Some(schema) = &incoming.schema {
1338        if base.schema.is_none() {
1339            base.schema = Some(schema.clone());
1340        } else if base.schema.as_ref() != Some(schema) {
1341            tracing::warn!(
1342                key = %secret_key_string(base),
1343                "conflicting secret schema encountered; keeping first"
1344            );
1345        }
1346    }
1347
1348    if !incoming.examples.is_empty() {
1349        for example in &incoming.examples {
1350            if !base.examples.contains(example) {
1351                base.examples.push(example.clone());
1352            }
1353        }
1354    }
1355
1356    base.required = base.required || incoming.required;
1357}
1358
1359fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1360    let parts: Vec<_> = raw.split('/').collect();
1361    if parts.len() < 2 || parts.len() > 3 {
1362        anyhow::bail!(
1363            "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1364            raw
1365        );
1366    }
1367    Ok(SecretScope {
1368        env: parts[0].to_string(),
1369        tenant: parts[1].to_string(),
1370        team: parts.get(2).map(|s| s.to_string()),
1371    })
1372}
1373
1374fn write_secret_requirements_file(
1375    pack_root: &Path,
1376    requirements: &[SecretRequirement],
1377    logical_name: &str,
1378) -> Result<PathBuf> {
1379    let path = pack_root.join(".packc").join(logical_name);
1380    if let Some(parent) = path.parent() {
1381        fs::create_dir_all(parent)
1382            .with_context(|| format!("failed to create {}", parent.display()))?;
1383    }
1384    let data = serde_json::to_vec_pretty(&requirements)
1385        .context("failed to serialise secret requirements")?;
1386    fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1387    Ok(path)
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393    use crate::config::BootstrapConfig;
1394    use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1395    use greentic_types::flow::FlowKind;
1396    use serde_json::json;
1397    use sha2::{Digest, Sha256};
1398    use std::fs::File;
1399    use std::io::Read;
1400    use std::{fs, path::PathBuf};
1401    use tempfile::tempdir;
1402    use zip::ZipArchive;
1403
1404    #[test]
1405    fn map_kind_accepts_known_values() {
1406        assert!(matches!(
1407            map_kind("application").unwrap(),
1408            PackKind::Application
1409        ));
1410        assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1411        assert!(matches!(
1412            map_kind("infrastructure").unwrap(),
1413            PackKind::Infrastructure
1414        ));
1415        assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1416        assert!(map_kind("unknown").is_err());
1417    }
1418
1419    #[test]
1420    fn collect_assets_preserves_relative_paths() {
1421        let root = PathBuf::from("/packs/demo");
1422        let assets = vec![AssetConfig {
1423            path: root.join("assets").join("foo.txt"),
1424        }];
1425        let collected = collect_assets(&assets, &root).expect("collect assets");
1426        assert_eq!(collected[0].logical_path, "assets/foo.txt");
1427    }
1428
1429    #[test]
1430    fn build_bootstrap_requires_known_references() {
1431        let config = pack_config_with_bootstrap(BootstrapConfig {
1432            install_flow: Some("flow.a".to_string()),
1433            upgrade_flow: None,
1434            installer_component: Some("component.a".to_string()),
1435        });
1436        let flows = vec![flow_entry("flow.a")];
1437        let components = vec![minimal_component_manifest("component.a")];
1438
1439        let bootstrap = build_bootstrap(&config, &flows, &components)
1440            .expect("bootstrap populated")
1441            .expect("bootstrap present");
1442
1443        assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
1444        assert_eq!(bootstrap.upgrade_flow, None);
1445        assert_eq!(
1446            bootstrap.installer_component.as_deref(),
1447            Some("component.a")
1448        );
1449    }
1450
1451    #[test]
1452    fn build_bootstrap_rejects_unknown_flow() {
1453        let config = pack_config_with_bootstrap(BootstrapConfig {
1454            install_flow: Some("missing".to_string()),
1455            upgrade_flow: None,
1456            installer_component: Some("component.a".to_string()),
1457        });
1458        let flows = vec![flow_entry("flow.a")];
1459        let components = vec![minimal_component_manifest("component.a")];
1460
1461        let err = build_bootstrap(&config, &flows, &components).unwrap_err();
1462        assert!(
1463            err.to_string()
1464                .contains("bootstrap.install_flow references unknown flow"),
1465            "unexpected error: {err}"
1466        );
1467    }
1468
1469    #[test]
1470    fn component_manifest_without_dev_flows_defaults_to_empty() {
1471        let manifest: ComponentManifest = serde_json::from_value(json!({
1472            "id": "component.dev",
1473            "version": "1.0.0",
1474            "supports": ["messaging"],
1475            "world": "greentic:demo@1.0.0",
1476            "profiles": { "default": "default", "supported": ["default"] },
1477            "capabilities": { "wasi": {}, "host": {} },
1478            "operations": [],
1479            "resources": {}
1480        }))
1481        .expect("manifest without dev_flows");
1482
1483        assert!(manifest.dev_flows.is_empty());
1484
1485        let pack_manifest = pack_manifest_with_component(manifest.clone());
1486        let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1487        let decoded: PackManifest =
1488            greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
1489        let stored = decoded
1490            .components
1491            .iter()
1492            .find(|item| item.id == manifest.id)
1493            .expect("component present");
1494        assert!(stored.dev_flows.is_empty());
1495    }
1496
1497    #[test]
1498    fn dev_flows_round_trip_in_manifest_and_gtpack() {
1499        let component = manifest_with_dev_flow();
1500        let pack_manifest = pack_manifest_with_component(component.clone());
1501        let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1502
1503        let decoded: PackManifest =
1504            greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
1505        let decoded_component = decoded
1506            .components
1507            .iter()
1508            .find(|item| item.id == component.id)
1509            .expect("component present");
1510        assert_eq!(decoded_component.dev_flows, component.dev_flows);
1511
1512        let temp = tempdir().expect("temp dir");
1513        let wasm_path = temp.path().join("component.wasm");
1514        write_stub_wasm(&wasm_path).expect("write stub wasm");
1515
1516        let build = BuildProducts {
1517            manifest: pack_manifest,
1518            components: vec![ComponentBinary {
1519                id: component.id.to_string(),
1520                source: wasm_path,
1521                manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
1522                manifest_path: format!("components/{}.manifest.cbor", component.id),
1523                manifest_hash_sha256: {
1524                    let mut sha = Sha256::new();
1525                    sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
1526                    format!("sha256:{:x}", sha.finalize())
1527                },
1528            }],
1529            lock_components: Vec::new(),
1530            flow_files: Vec::new(),
1531            assets: Vec::new(),
1532        };
1533
1534        let out = temp.path().join("demo.gtpack");
1535        package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
1536
1537        let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
1538            .expect("read gtpack archive");
1539        let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
1540        let mut stored = Vec::new();
1541        manifest_entry
1542            .read_to_end(&mut stored)
1543            .expect("read manifest");
1544        let decoded: PackManifest =
1545            greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
1546
1547        let stored_component = decoded
1548            .components
1549            .iter()
1550            .find(|item| item.id == component.id)
1551            .expect("component preserved");
1552        assert_eq!(stored_component.dev_flows, component.dev_flows);
1553    }
1554
1555    #[test]
1556    fn component_sources_extension_respects_bundle() {
1557        let lock_tag = PackLockV1::new(vec![LockedComponent {
1558            name: "demo.tagged".into(),
1559            r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
1560            digest: "sha256:deadbeef".into(),
1561            component_id: None,
1562            bundled: true,
1563            bundled_path: Some("blobs/sha256/deadbeef.wasm".into()),
1564            wasm_sha256: Some("deadbeef".repeat(8)),
1565            resolved_digest: Some("sha256:deadbeef".into()),
1566        }]);
1567
1568        let ext_none =
1569            merge_component_sources_extension(None, &lock_tag, BundleMode::None).expect("ext");
1570        let value = match ext_none
1571            .unwrap()
1572            .get(EXT_COMPONENT_SOURCES_V1)
1573            .and_then(|e| e.inline.as_ref())
1574        {
1575            Some(ExtensionInline::Other(v)) => v.clone(),
1576            _ => panic!("missing inline"),
1577        };
1578        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1579        assert!(matches!(
1580            decoded.components[0].artifact,
1581            ArtifactLocationV1::Inline { .. }
1582        ));
1583
1584        let lock_digest = PackLockV1::new(vec![LockedComponent {
1585            name: "demo.component".into(),
1586            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
1587            digest: "sha256:deadbeef".into(),
1588            component_id: None,
1589            bundled: false,
1590            bundled_path: None,
1591            wasm_sha256: None,
1592            resolved_digest: None,
1593        }]);
1594
1595        let ext_none =
1596            merge_component_sources_extension(None, &lock_digest, BundleMode::None).expect("ext");
1597        let value = match ext_none
1598            .unwrap()
1599            .get(EXT_COMPONENT_SOURCES_V1)
1600            .and_then(|e| e.inline.as_ref())
1601        {
1602            Some(ExtensionInline::Other(v)) => v.clone(),
1603            _ => panic!("missing inline"),
1604        };
1605        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1606        assert!(matches!(
1607            decoded.components[0].artifact,
1608            ArtifactLocationV1::Remote
1609        ));
1610
1611        let lock_digest_bundled = PackLockV1::new(vec![LockedComponent {
1612            name: "demo.component".into(),
1613            r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
1614            digest: "sha256:deadbeef".into(),
1615            component_id: None,
1616            bundled: true,
1617            bundled_path: Some("components/demo.component.wasm".into()),
1618            wasm_sha256: Some("deadbeef".repeat(8)),
1619            resolved_digest: None,
1620        }]);
1621
1622        let ext_cache =
1623            merge_component_sources_extension(None, &lock_digest_bundled, BundleMode::Cache)
1624                .expect("ext");
1625        let value = match ext_cache
1626            .unwrap()
1627            .get(EXT_COMPONENT_SOURCES_V1)
1628            .and_then(|e| e.inline.as_ref())
1629        {
1630            Some(ExtensionInline::Other(v)) => v.clone(),
1631            _ => panic!("missing inline"),
1632        };
1633        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1634        assert!(matches!(
1635            decoded.components[0].artifact,
1636            ArtifactLocationV1::Inline { .. }
1637        ));
1638    }
1639
1640    #[test]
1641    fn component_sources_extension_skips_file_refs() {
1642        let lock = PackLockV1::new(vec![LockedComponent {
1643            name: "local.component".into(),
1644            r#ref: "file:///tmp/component.wasm".into(),
1645            digest: "sha256:deadbeef".into(),
1646            component_id: None,
1647            bundled: false,
1648            bundled_path: None,
1649            wasm_sha256: None,
1650            resolved_digest: None,
1651        }]);
1652
1653        let ext_none =
1654            merge_component_sources_extension(None, &lock, BundleMode::Cache).expect("ext");
1655        assert!(ext_none.is_none(), "file refs should be omitted");
1656
1657        let lock = PackLockV1::new(vec![
1658            LockedComponent {
1659                name: "local.component".into(),
1660                r#ref: "file:///tmp/component.wasm".into(),
1661                digest: "sha256:deadbeef".into(),
1662                component_id: None,
1663                bundled: false,
1664                bundled_path: None,
1665                wasm_sha256: None,
1666                resolved_digest: None,
1667            },
1668            LockedComponent {
1669                name: "remote.component".into(),
1670                r#ref: "oci://ghcr.io/demo/component:2.0.0".into(),
1671                digest: "sha256:cafebabe".into(),
1672                component_id: None,
1673                bundled: false,
1674                bundled_path: None,
1675                wasm_sha256: None,
1676                resolved_digest: None,
1677            },
1678        ]);
1679
1680        let ext_some =
1681            merge_component_sources_extension(None, &lock, BundleMode::None).expect("ext");
1682        let value = match ext_some
1683            .unwrap()
1684            .get(EXT_COMPONENT_SOURCES_V1)
1685            .and_then(|e| e.inline.as_ref())
1686        {
1687            Some(ExtensionInline::Other(v)) => v.clone(),
1688            _ => panic!("missing inline"),
1689        };
1690        let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1691        assert_eq!(decoded.components.len(), 1);
1692        assert!(matches!(
1693            decoded.components[0].source,
1694            ComponentSourceRef::Oci(_)
1695        ));
1696    }
1697
1698    #[test]
1699    fn build_embeds_lock_components_from_cache() {
1700        let rt = tokio::runtime::Runtime::new().expect("runtime");
1701        rt.block_on(async {
1702            let temp = tempdir().expect("temp dir");
1703            let pack_dir = temp.path().join("pack");
1704            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
1705            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
1706
1707            let wasm_path = pack_dir.join("components/dummy.wasm");
1708            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
1709                .expect("write wasm");
1710
1711            let flow_path = pack_dir.join("flows/main.ygtc");
1712            fs::write(
1713                &flow_path,
1714                r#"id: main
1715type: messaging
1716start: call
1717nodes:
1718  call:
1719    handle_message:
1720      text: "hi"
1721    routing: out
1722"#,
1723            )
1724            .expect("write flow");
1725
1726            let cache_dir = temp.path().join("cache");
1727            let cached_bytes = b"cached-component";
1728            let digest = format!("sha256:{:x}", Sha256::digest(cached_bytes));
1729            let cache_path = cache_dir
1730                .join(digest.trim_start_matches("sha256:"))
1731                .join("component.wasm");
1732            fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache dir");
1733            fs::write(&cache_path, cached_bytes).expect("write cached");
1734
1735            let summary = serde_json::json!({
1736                "schema_version": 1,
1737                "flow": "main.ygtc",
1738                "nodes": {
1739                    "call": {
1740                        "component_id": "dummy.component",
1741                        "source": {
1742                            "kind": "oci",
1743                            "ref": format!("oci://ghcr.io/demo/component@{digest}")
1744                        },
1745                        "digest": digest
1746                    }
1747                }
1748            });
1749            fs::write(
1750                flow_path.with_extension("ygtc.resolve.summary.json"),
1751                serde_json::to_vec_pretty(&summary).expect("summary json"),
1752            )
1753            .expect("write summary");
1754
1755            let pack_yaml = r#"pack_id: demo.lock-bundle
1756version: 0.1.0
1757kind: application
1758publisher: Test
1759components:
1760  - id: dummy.component
1761    version: "0.1.0"
1762    world: "greentic:component/component@0.5.0"
1763    supports: ["messaging"]
1764    profiles:
1765      default: "stateless"
1766      supported: ["stateless"]
1767    capabilities:
1768      wasi: {}
1769      host: {}
1770    operations:
1771      - name: "handle_message"
1772        input_schema: {}
1773        output_schema: {}
1774    wasm: "components/dummy.wasm"
1775flows:
1776  - id: main
1777    file: flows/main.ygtc
1778    tags: [default]
1779    entrypoints: [main]
1780"#;
1781            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
1782
1783            let runtime = crate::runtime::resolve_runtime(
1784                Some(pack_dir.as_path()),
1785                Some(cache_dir.as_path()),
1786                true,
1787                None,
1788            )
1789            .expect("runtime");
1790
1791            let opts = BuildOptions {
1792                pack_dir: pack_dir.clone(),
1793                component_out: None,
1794                manifest_out: pack_dir.join("dist/manifest.cbor"),
1795                sbom_out: None,
1796                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
1797                lock_path: pack_dir.join("pack.lock.json"),
1798                bundle: BundleMode::Cache,
1799                dry_run: false,
1800                secrets_req: None,
1801                default_secret_scope: None,
1802                allow_oci_tags: false,
1803                runtime,
1804                skip_update: false,
1805            };
1806
1807            run(&opts).await.expect("build");
1808
1809            let gtpack_path = opts.gtpack_out.expect("gtpack path");
1810            let mut archive = ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
1811                .expect("read gtpack");
1812            assert!(
1813                archive.by_name("components/main___call.wasm").is_ok(),
1814                "missing lock component artifact in gtpack"
1815            );
1816        });
1817    }
1818
1819    #[test]
1820    #[ignore = "requires network access to fetch OCI component"]
1821    fn build_fetches_and_embeds_lock_components_online() {
1822        if std::env::var("GREENTIC_PACK_ONLINE").is_err() {
1823            return;
1824        }
1825        let rt = tokio::runtime::Runtime::new().expect("runtime");
1826        rt.block_on(async {
1827            let temp = tempdir().expect("temp dir");
1828            let pack_dir = temp.path().join("pack");
1829            fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
1830            fs::create_dir_all(pack_dir.join("components")).expect("components dir");
1831
1832            let wasm_path = pack_dir.join("components/dummy.wasm");
1833            fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
1834                .expect("write wasm");
1835
1836            let flow_path = pack_dir.join("flows/main.ygtc");
1837            fs::write(
1838                &flow_path,
1839                r#"id: main
1840type: messaging
1841start: call
1842nodes:
1843  call:
1844    handle_message:
1845      text: "hi"
1846    routing: out
1847"#,
1848            )
1849            .expect("write flow");
1850
1851            let digest =
1852                "sha256:0904bee6ecd737506265e3f38f3e4fe6b185c20fd1b0e7c06ce03cdeedc00340";
1853            let summary = serde_json::json!({
1854                "schema_version": 1,
1855                "flow": "main.ygtc",
1856                "nodes": {
1857                    "call": {
1858                        "component_id": "dummy.component",
1859                        "source": {
1860                            "kind": "oci",
1861                            "ref": format!("oci://ghcr.io/greentic-ai/components/templates@{digest}")
1862                        },
1863                        "digest": digest
1864                    }
1865                }
1866            });
1867            fs::write(
1868                flow_path.with_extension("ygtc.resolve.summary.json"),
1869                serde_json::to_vec_pretty(&summary).expect("summary json"),
1870            )
1871            .expect("write summary");
1872
1873            let pack_yaml = r#"pack_id: demo.lock-online
1874version: 0.1.0
1875kind: application
1876publisher: Test
1877components:
1878  - id: dummy.component
1879    version: "0.1.0"
1880    world: "greentic:component/component@0.5.0"
1881    supports: ["messaging"]
1882    profiles:
1883      default: "stateless"
1884      supported: ["stateless"]
1885    capabilities:
1886      wasi: {}
1887      host: {}
1888    operations:
1889      - name: "handle_message"
1890        input_schema: {}
1891        output_schema: {}
1892    wasm: "components/dummy.wasm"
1893flows:
1894  - id: main
1895    file: flows/main.ygtc
1896    tags: [default]
1897    entrypoints: [main]
1898"#;
1899            fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
1900
1901            let cache_dir = temp.path().join("cache");
1902            let runtime = crate::runtime::resolve_runtime(
1903                Some(pack_dir.as_path()),
1904                Some(cache_dir.as_path()),
1905                false,
1906                None,
1907            )
1908            .expect("runtime");
1909
1910            let opts = BuildOptions {
1911                pack_dir: pack_dir.clone(),
1912                component_out: None,
1913                manifest_out: pack_dir.join("dist/manifest.cbor"),
1914                sbom_out: None,
1915                gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
1916                lock_path: pack_dir.join("pack.lock.json"),
1917                bundle: BundleMode::Cache,
1918                dry_run: false,
1919                secrets_req: None,
1920                default_secret_scope: None,
1921                allow_oci_tags: false,
1922                runtime,
1923                skip_update: false,
1924            };
1925
1926            run(&opts).await.expect("build");
1927
1928            let gtpack_path = opts.gtpack_out.expect("gtpack path");
1929            let mut archive =
1930                ZipArchive::new(File::open(&gtpack_path).expect("open gtpack"))
1931                    .expect("read gtpack");
1932            assert!(
1933                archive.by_name("components/main___call.wasm").is_ok(),
1934                "missing lock component artifact in gtpack"
1935            );
1936        });
1937    }
1938
1939    #[test]
1940    fn aggregate_secret_requirements_dedupes_and_sorts() {
1941        let component: ComponentConfig = serde_json::from_value(json!({
1942            "id": "component.a",
1943            "version": "1.0.0",
1944            "world": "greentic:demo@1.0.0",
1945            "supports": [],
1946            "profiles": { "default": "default", "supported": ["default"] },
1947            "capabilities": {
1948                "wasi": {},
1949                "host": {
1950                    "secrets": {
1951                        "required": [
1952                    {
1953                        "key": "db/password",
1954                        "required": true,
1955                        "scope": { "env": "dev", "tenant": "t1" },
1956                        "format": "text",
1957                        "description": "primary"
1958                    }
1959                ]
1960            }
1961        }
1962            },
1963            "wasm": "component.wasm",
1964            "operations": [],
1965            "resources": {}
1966        }))
1967        .expect("component config");
1968
1969        let dupe: ComponentConfig = serde_json::from_value(json!({
1970            "id": "component.b",
1971            "version": "1.0.0",
1972            "world": "greentic:demo@1.0.0",
1973            "supports": [],
1974            "profiles": { "default": "default", "supported": ["default"] },
1975            "capabilities": {
1976                "wasi": {},
1977                "host": {
1978                    "secrets": {
1979                        "required": [
1980                            {
1981                        "key": "db/password",
1982                        "required": true,
1983                        "scope": { "env": "dev", "tenant": "t1" },
1984                        "format": "text",
1985                        "description": "secondary",
1986                        "examples": ["example"]
1987                    }
1988                ]
1989            }
1990                }
1991            },
1992            "wasm": "component.wasm",
1993            "operations": [],
1994            "resources": {}
1995        }))
1996        .expect("component config");
1997
1998        let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
1999            .expect("aggregate secrets");
2000        assert_eq!(reqs.len(), 1);
2001        let req = &reqs[0];
2002        assert_eq!(req.description.as_deref(), Some("primary"));
2003        assert!(req.examples.contains(&"example".to_string()));
2004    }
2005
2006    fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
2007        PackConfig {
2008            pack_id: "demo.pack".to_string(),
2009            version: "1.0.0".to_string(),
2010            kind: "application".to_string(),
2011            publisher: "demo".to_string(),
2012            bootstrap: Some(bootstrap),
2013            components: Vec::new(),
2014            dependencies: Vec::new(),
2015            flows: Vec::new(),
2016            assets: Vec::new(),
2017            extensions: None,
2018        }
2019    }
2020
2021    fn flow_entry(id: &str) -> PackFlowEntry {
2022        let flow: Flow = serde_json::from_value(json!({
2023            "schema_version": "flow/v1",
2024            "id": id,
2025            "kind": "messaging"
2026        }))
2027        .expect("flow json");
2028
2029        PackFlowEntry {
2030            id: FlowId::new(id).expect("flow id"),
2031            kind: FlowKind::Messaging,
2032            flow,
2033            tags: Vec::new(),
2034            entrypoints: Vec::new(),
2035        }
2036    }
2037
2038    fn minimal_component_manifest(id: &str) -> ComponentManifest {
2039        serde_json::from_value(json!({
2040            "id": id,
2041            "version": "1.0.0",
2042            "supports": [],
2043            "world": "greentic:demo@1.0.0",
2044            "profiles": { "default": "default", "supported": ["default"] },
2045            "capabilities": { "wasi": {}, "host": {} },
2046            "operations": [],
2047            "resources": {}
2048        }))
2049        .expect("component manifest")
2050    }
2051
2052    fn manifest_with_dev_flow() -> ComponentManifest {
2053        serde_json::from_str(include_str!(
2054            "../tests/fixtures/component_manifest_with_dev_flows.json"
2055        ))
2056        .expect("fixture manifest")
2057    }
2058
2059    fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
2060        let flow = serde_json::from_value(json!({
2061            "schema_version": "flow/v1",
2062            "id": "flow.dev",
2063            "kind": "messaging"
2064        }))
2065        .expect("flow json");
2066
2067        PackManifest {
2068            schema_version: "pack-v1".to_string(),
2069            pack_id: PackId::new("demo.pack").expect("pack id"),
2070            version: Version::parse("1.0.0").expect("version"),
2071            kind: PackKind::Application,
2072            publisher: "demo".to_string(),
2073            components: vec![component],
2074            flows: vec![PackFlowEntry {
2075                id: FlowId::new("flow.dev").expect("flow id"),
2076                kind: FlowKind::Messaging,
2077                flow,
2078                tags: Vec::new(),
2079                entrypoints: Vec::new(),
2080            }],
2081            dependencies: Vec::new(),
2082            capabilities: Vec::new(),
2083            secret_requirements: Vec::new(),
2084            signatures: PackSignatures::default(),
2085            bootstrap: None,
2086            extensions: None,
2087        }
2088    }
2089}