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