Skip to main content

packc/
build.rs

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