packc/
build.rs

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