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