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::pack_manifest::{ExtensionInline as PackManifestExtensionInline, ExtensionRef};
26use greentic_types::{
27 BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
28 ComponentOperation, ExtensionInline, Flow, FlowId, PackDependency, PackFlowEntry, PackId,
29 PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
30 encode_pack_manifest,
31};
32use semver::Version;
33use serde::Serialize;
34use serde_cbor;
35use serde_json::json;
36use serde_yaml_bw::Value as YamlValue;
37use sha2::{Digest, Sha256};
38use std::collections::{BTreeMap, BTreeSet};
39use std::fs;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42use std::str::FromStr;
43use tracing::{info, warn};
44use walkdir::WalkDir;
45use zip::write::SimpleFileOptions;
46use zip::{CompressionMethod, ZipWriter};
47
48const SBOM_FORMAT: &str = "greentic-sbom-v1";
49const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
50
51#[derive(Serialize)]
52struct SbomDocument {
53 format: String,
54 files: Vec<SbomEntry>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
58pub enum BundleMode {
59 Cache,
60 None,
61}
62
63#[derive(Clone)]
64pub struct BuildOptions {
65 pub pack_dir: PathBuf,
66 pub component_out: Option<PathBuf>,
67 pub manifest_out: PathBuf,
68 pub sbom_out: Option<PathBuf>,
69 pub gtpack_out: Option<PathBuf>,
70 pub lock_path: PathBuf,
71 pub bundle: BundleMode,
72 pub dry_run: bool,
73 pub secrets_req: Option<PathBuf>,
74 pub default_secret_scope: Option<String>,
75 pub allow_oci_tags: bool,
76 pub require_component_manifests: bool,
77 pub no_extra_dirs: bool,
78 pub dev: bool,
79 pub runtime: RuntimeContext,
80 pub skip_update: bool,
81}
82
83impl BuildOptions {
84 pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
85 let pack_dir = args
86 .input
87 .canonicalize()
88 .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
89
90 let component_out = args
91 .component_out
92 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
93 let manifest_out = args
94 .manifest
95 .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
96 .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
97 let sbom_out = args
98 .sbom
99 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
100 let default_gtpack_name = pack_dir
101 .file_name()
102 .and_then(|name| name.to_str())
103 .unwrap_or("pack");
104 let default_gtpack_out = pack_dir
105 .join("dist")
106 .join(format!("{default_gtpack_name}.gtpack"));
107 let gtpack_out = Some(
108 args.gtpack_out
109 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
110 .unwrap_or(default_gtpack_out),
111 );
112 let lock_path = args
113 .lock
114 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
115 .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
116
117 Ok(Self {
118 pack_dir,
119 component_out,
120 manifest_out,
121 sbom_out,
122 gtpack_out,
123 lock_path,
124 bundle: args.bundle,
125 dry_run: args.dry_run,
126 secrets_req: args.secrets_req,
127 default_secret_scope: args.default_secret_scope,
128 allow_oci_tags: args.allow_oci_tags,
129 require_component_manifests: args.require_component_manifests,
130 no_extra_dirs: args.no_extra_dirs,
131 dev: args.dev,
132 runtime: runtime.clone(),
133 skip_update: args.no_update,
134 })
135 }
136}
137
138pub async fn run(opts: &BuildOptions) -> Result<()> {
139 info!(
140 pack_dir = %opts.pack_dir.display(),
141 manifest_out = %opts.manifest_out.display(),
142 gtpack_out = ?opts.gtpack_out,
143 dry_run = opts.dry_run,
144 "building greentic pack"
145 );
146
147 if !opts.skip_update {
148 crate::cli::update::update_pack(&opts.pack_dir, false)?;
150 }
151
152 resolve::handle(
155 ResolveArgs {
156 input: opts.pack_dir.clone(),
157 lock: Some(opts.lock_path.clone()),
158 },
159 &opts.runtime,
160 false,
161 )
162 .await?;
163
164 let config = crate::config::load_pack_config(&opts.pack_dir)?;
165 info!(
166 id = %config.pack_id,
167 version = %config.version,
168 kind = %config.kind,
169 components = config.components.len(),
170 flows = config.flows.len(),
171 dependencies = config.dependencies.len(),
172 "loaded pack.yaml"
173 );
174 validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
175
176 let secret_requirements_override =
177 resolve_secret_requirements_override(&opts.pack_dir, opts.secrets_req.as_ref());
178 let secret_requirements = aggregate_secret_requirements(
179 &config.components,
180 secret_requirements_override.as_deref(),
181 opts.default_secret_scope.as_deref(),
182 )?;
183
184 if !opts.lock_path.exists() {
185 anyhow::bail!(
186 "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
187 opts.lock_path.display()
188 );
189 }
190 let mut pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
191 format!(
192 "failed to read pack lock {} (try `greentic-pack resolve`)",
193 opts.lock_path.display()
194 )
195 })?;
196
197 let mut build = assemble_manifest(
198 &config,
199 &opts.pack_dir,
200 &secret_requirements,
201 !opts.no_extra_dirs,
202 opts.dev,
203 )?;
204 build.lock_components =
205 collect_lock_component_artifacts(&mut pack_lock, &opts.runtime, opts.bundle, opts.dry_run)
206 .await?;
207
208 let materialized = materialize_flow_components(
209 &opts.pack_dir,
210 &build.manifest.flows,
211 &pack_lock,
212 &build.components,
213 &build.lock_components,
214 opts.require_component_manifests,
215 )?;
216 build.manifest.components.extend(materialized.components);
217 build.component_manifest_files = materialized.manifest_files;
218 build.manifest.components.sort_by(|a, b| a.id.cmp(&b.id));
219
220 let component_manifest_files =
221 collect_component_manifest_files(&build.components, &build.component_manifest_files);
222 build.manifest.extensions =
223 merge_component_manifest_extension(build.manifest.extensions, &component_manifest_files)?;
224 build.manifest.extensions = merge_component_sources_extension(
225 build.manifest.extensions,
226 &pack_lock,
227 opts.bundle,
228 materialized.manifest_paths.as_ref(),
229 )?;
230 if !opts.dry_run {
231 greentic_pack::pack_lock::write_pack_lock(&opts.lock_path, &pack_lock)?;
232 }
233
234 let manifest_bytes = encode_pack_manifest(&build.manifest)?;
235 info!(len = manifest_bytes.len(), "encoded manifest.cbor");
236
237 if opts.dry_run {
238 info!("dry-run complete; no files written");
239 return Ok(());
240 }
241
242 if let Some(component_out) = opts.component_out.as_ref() {
243 write_stub_wasm(component_out)?;
244 }
245
246 write_bytes(&opts.manifest_out, &manifest_bytes)?;
247
248 if let Some(sbom_out) = opts.sbom_out.as_ref() {
249 write_bytes(sbom_out, br#"{"files":[]} "#)?;
250 }
251
252 if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
253 let mut build = build;
254 if opts.dev && !secret_requirements.is_empty() {
255 let logical = "secret-requirements.json".to_string();
256 let req_path =
257 write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
258 build.assets.push(AssetFile {
259 logical_path: logical,
260 source: req_path,
261 });
262 }
263 let warnings = package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle, opts.dev)?;
264 for warning in warnings {
265 warn!(warning);
266 }
267 info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
268 eprintln!("wrote {}", gtpack_out.display());
269 }
270
271 Ok(())
272}
273
274struct BuildProducts {
275 manifest: PackManifest,
276 components: Vec<ComponentBinary>,
277 lock_components: Vec<LockComponentBinary>,
278 component_manifest_files: Vec<ComponentManifestFile>,
279 flow_files: Vec<FlowFile>,
280 assets: Vec<AssetFile>,
281 extra_files: Vec<ExtraFile>,
282}
283
284#[derive(Clone)]
285struct ComponentBinary {
286 id: String,
287 source: PathBuf,
288 manifest_bytes: Vec<u8>,
289 manifest_path: String,
290 manifest_hash_sha256: String,
291}
292
293#[derive(Clone)]
294struct LockComponentBinary {
295 logical_path: String,
296 source: PathBuf,
297}
298
299#[derive(Clone)]
300struct ComponentManifestFile {
301 component_id: String,
302 manifest_path: String,
303 manifest_bytes: Vec<u8>,
304 manifest_hash_sha256: String,
305}
306
307struct AssetFile {
308 logical_path: String,
309 source: PathBuf,
310}
311
312struct ExtraFile {
313 logical_path: String,
314 source: PathBuf,
315}
316
317#[derive(Clone)]
318struct FlowFile {
319 logical_path: String,
320 bytes: Vec<u8>,
321 media_type: &'static str,
322}
323
324fn assemble_manifest(
325 config: &PackConfig,
326 pack_root: &Path,
327 secret_requirements: &[SecretRequirement],
328 include_extra_dirs: bool,
329 dev_mode: bool,
330) -> Result<BuildProducts> {
331 let components = build_components(&config.components)?;
332 let (flows, flow_files) = build_flows(&config.flows, pack_root)?;
333 let dependencies = build_dependencies(&config.dependencies)?;
334 let assets = collect_assets(&config.assets, pack_root)?;
335 let extra_files = if include_extra_dirs {
336 collect_extra_dir_files(pack_root)?
337 } else {
338 Vec::new()
339 };
340 let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
341 let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
342 let extensions = normalize_extensions(&config.extensions);
343
344 let mut manifest = PackManifest {
345 schema_version: "pack-v1".to_string(),
346 pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
347 name: config.name.clone(),
348 version: Version::parse(&config.version)
349 .context("invalid pack version (expected semver)")?,
350 kind: map_kind(&config.kind)?,
351 publisher: config.publisher.clone(),
352 components: component_manifests,
353 flows,
354 dependencies,
355 capabilities: derive_pack_capabilities(&components),
356 secret_requirements: secret_requirements.to_vec(),
357 signatures: PackSignatures::default(),
358 bootstrap,
359 extensions,
360 };
361
362 annotate_manifest_build_mode(&mut manifest, dev_mode);
363
364 Ok(BuildProducts {
365 manifest,
366 components: components.into_iter().map(|(_, bin)| bin).collect(),
367 lock_components: Vec::new(),
368 component_manifest_files: Vec::new(),
369 flow_files,
370 assets,
371 extra_files,
372 })
373}
374
375fn annotate_manifest_build_mode(manifest: &mut PackManifest, dev_mode: bool) {
376 let extensions = manifest.extensions.get_or_insert_with(BTreeMap::new);
377 extensions.insert(
378 EXT_BUILD_MODE_ID.to_string(),
379 ExtensionRef {
380 kind: EXT_BUILD_MODE_ID.to_string(),
381 version: "1".to_string(),
382 digest: None,
383 location: None,
384 inline: Some(PackManifestExtensionInline::Other(json!({
385 "mode": if dev_mode { "dev" } else { "prod" }
386 }))),
387 },
388 );
389}
390
391fn build_components(
392 configs: &[ComponentConfig],
393) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
394 let mut seen = BTreeSet::new();
395 let mut result = Vec::new();
396
397 for cfg in configs {
398 if !seen.insert(cfg.id.clone()) {
399 anyhow::bail!("duplicate component id {}", cfg.id);
400 }
401
402 info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
403 let (manifest, binary) = resolve_component_artifacts(cfg)?;
404
405 result.push((manifest, binary));
406 }
407
408 Ok(result)
409}
410
411fn resolve_component_artifacts(
412 cfg: &ComponentConfig,
413) -> Result<(ComponentManifest, ComponentBinary)> {
414 let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
415
416 let mut manifest =
417 if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm, &cfg.id)? {
418 if from_disk.id.to_string() != cfg.id {
419 anyhow::bail!(
420 "component manifest id {} does not match pack.yaml id {}",
421 from_disk.id,
422 cfg.id
423 );
424 }
425 if from_disk.version.to_string() != cfg.version {
426 anyhow::bail!(
427 "component manifest version {} does not match pack.yaml version {}",
428 from_disk.version,
429 cfg.version
430 );
431 }
432 from_disk
433 } else {
434 manifest_from_config(cfg)?
435 };
436
437 if manifest.operations.is_empty() && !cfg.operations.is_empty() {
439 manifest.operations = cfg
440 .operations
441 .iter()
442 .map(operation_from_config)
443 .collect::<Result<Vec<_>>>()?;
444 }
445
446 let manifest_bytes =
447 serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
448 let mut sha = Sha256::new();
449 sha.update(&manifest_bytes);
450 let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
451 let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
452
453 let binary = ComponentBinary {
454 id: cfg.id.clone(),
455 source: resolved_wasm,
456 manifest_bytes,
457 manifest_path,
458 manifest_hash_sha256,
459 };
460
461 Ok((manifest, binary))
462}
463
464fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
465 Ok(ComponentManifest {
466 id: ComponentId::new(cfg.id.clone())
467 .with_context(|| format!("invalid component id {}", cfg.id))?,
468 version: Version::parse(&cfg.version)
469 .context("invalid component version (expected semver)")?,
470 supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
471 world: cfg.world.clone(),
472 profiles: cfg.profiles.clone(),
473 capabilities: cfg.capabilities.clone(),
474 configurators: convert_configurators(cfg)?,
475 operations: cfg
476 .operations
477 .iter()
478 .map(operation_from_config)
479 .collect::<Result<Vec<_>>>()?,
480 config_schema: cfg.config_schema.clone(),
481 resources: cfg.resources.clone().unwrap_or_default(),
482 dev_flows: BTreeMap::new(),
483 })
484}
485
486fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
487 if path.is_file() {
488 return Ok(path.to_path_buf());
489 }
490 if !path.exists() {
491 anyhow::bail!("component path {} does not exist", path.display());
492 }
493 if !path.is_dir() {
494 anyhow::bail!(
495 "component path {} must be a file or directory",
496 path.display()
497 );
498 }
499
500 let mut component_candidates = Vec::new();
501 let mut wasm_candidates = Vec::new();
502 let mut stack = vec![path.to_path_buf()];
503 while let Some(current) = stack.pop() {
504 for entry in fs::read_dir(¤t)
505 .with_context(|| format!("failed to list components in {}", current.display()))?
506 {
507 let entry = entry?;
508 let entry_type = entry.file_type()?;
509 let entry_path = entry.path();
510 if entry_type.is_dir() {
511 stack.push(entry_path);
512 continue;
513 }
514 if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
515 {
516 let file_name = entry_path
517 .file_name()
518 .and_then(|n| n.to_str())
519 .unwrap_or_default();
520 if file_name.ends_with(".component.wasm") {
521 component_candidates.push(entry_path);
522 } else {
523 wasm_candidates.push(entry_path);
524 }
525 }
526 }
527 }
528
529 let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
530 list.sort();
531 if list.len() == 1 {
532 Ok(list.remove(0))
533 } else {
534 let options = list
535 .iter()
536 .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
537 .collect::<Vec<_>>()
538 .join(", ");
539 anyhow::bail!(
540 "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
541 path.display(),
542 options
543 );
544 }
545 };
546
547 if !component_candidates.is_empty() {
548 return choose(component_candidates);
549 }
550 if !wasm_candidates.is_empty() {
551 return choose(wasm_candidates);
552 }
553
554 anyhow::bail!(
555 "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
556 path.display()
557 );
558}
559
560fn load_component_manifest_from_disk(
561 path: &Path,
562 component_id: &str,
563) -> Result<Option<ComponentManifest>> {
564 let manifest_dir = if path.is_dir() {
565 path.to_path_buf()
566 } else {
567 path.parent()
568 .map(Path::to_path_buf)
569 .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
570 };
571 let mut candidates = vec![
572 manifest_dir.join("component.manifest.cbor"),
573 manifest_dir.join("component.manifest.json"),
574 manifest_dir.join("component.json"),
575 ];
576 let id_manifest_suffix = format!("{}.manifest", component_id);
577 candidates.push(manifest_dir.join(format!("{id_manifest_suffix}.cbor")));
578 candidates.push(manifest_dir.join(format!("{id_manifest_suffix}.json")));
579 candidates.push(manifest_dir.join(format!("{component_id}.json")));
580
581 for manifest_path in candidates {
582 if !manifest_path.exists() {
583 continue;
584 }
585 let manifest = load_component_manifest_from_file(&manifest_path)?;
586 return Ok(Some(manifest));
587 }
588
589 Ok(None)
590}
591
592fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
593 Ok(ComponentOperation {
594 name: cfg.name.clone(),
595 input_schema: cfg.input_schema.clone(),
596 output_schema: cfg.output_schema.clone(),
597 })
598}
599
600fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
601 let Some(configurators) = cfg.configurators.as_ref() else {
602 return Ok(None);
603 };
604
605 let basic = match &configurators.basic {
606 Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
607 None => None,
608 };
609 let full = match &configurators.full {
610 Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
611 None => None,
612 };
613
614 Ok(Some(ComponentConfigurators { basic, full }))
615}
616
617fn build_bootstrap(
618 config: &PackConfig,
619 flows: &[PackFlowEntry],
620 components: &[ComponentManifest],
621) -> Result<Option<BootstrapSpec>> {
622 let Some(raw) = config.bootstrap.as_ref() else {
623 return Ok(None);
624 };
625
626 let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
627 let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
628
629 let mut spec = BootstrapSpec::default();
630
631 if let Some(install_flow) = &raw.install_flow {
632 if !flow_ids.contains(install_flow) {
633 anyhow::bail!(
634 "bootstrap.install_flow references unknown flow {}",
635 install_flow
636 );
637 }
638 spec.install_flow = Some(install_flow.clone());
639 }
640
641 if let Some(upgrade_flow) = &raw.upgrade_flow {
642 if !flow_ids.contains(upgrade_flow) {
643 anyhow::bail!(
644 "bootstrap.upgrade_flow references unknown flow {}",
645 upgrade_flow
646 );
647 }
648 spec.upgrade_flow = Some(upgrade_flow.clone());
649 }
650
651 if let Some(component) = &raw.installer_component {
652 if !component_ids.contains(component) {
653 anyhow::bail!(
654 "bootstrap.installer_component references unknown component {}",
655 component
656 );
657 }
658 spec.installer_component = Some(component.clone());
659 }
660
661 if spec.install_flow.is_none()
662 && spec.upgrade_flow.is_none()
663 && spec.installer_component.is_none()
664 {
665 return Ok(None);
666 }
667
668 Ok(Some(spec))
669}
670
671fn build_flows(
672 configs: &[FlowConfig],
673 pack_root: &Path,
674) -> Result<(Vec<PackFlowEntry>, Vec<FlowFile>)> {
675 let mut seen = BTreeSet::new();
676 let mut entries = Vec::new();
677 let mut flow_files = Vec::new();
678
679 for cfg in configs {
680 info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
681 let yaml_bytes = fs::read(&cfg.file)
682 .with_context(|| format!("failed to read flow {}", cfg.file.display()))?;
683 let mut flow: Flow = compile_ygtc_file(&cfg.file)
684 .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
685 populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
686 format!(
687 "failed to resolve component.exec operations in {}",
688 cfg.file.display()
689 )
690 })?;
691 normalize_legacy_component_exec_ids(&mut flow)?;
692 let summary = load_flow_resolve_summary(pack_root, cfg, &flow)?;
693 apply_summary_component_ids(&mut flow, &summary).with_context(|| {
694 format!("failed to resolve component ids in {}", cfg.file.display())
695 })?;
696
697 let flow_id = flow.id.to_string();
698 if !seen.insert(flow_id.clone()) {
699 anyhow::bail!("duplicate flow id {}", flow_id);
700 }
701
702 let entrypoints = if cfg.entrypoints.is_empty() {
703 flow.entrypoints.keys().cloned().collect()
704 } else {
705 cfg.entrypoints.clone()
706 };
707
708 let flow_entry = PackFlowEntry {
709 id: flow.id.clone(),
710 kind: flow.kind,
711 flow,
712 tags: cfg.tags.clone(),
713 entrypoints,
714 };
715
716 let flow_id = flow_entry.id.to_string();
717 flow_files.push(FlowFile {
718 logical_path: format!("flows/{flow_id}/flow.ygtc"),
719 bytes: yaml_bytes,
720 media_type: "application/yaml",
721 });
722 flow_files.push(FlowFile {
723 logical_path: format!("flows/{flow_id}/flow.json"),
724 bytes: serde_json::to_vec(&flow_entry.flow).context("encode flow json")?,
725 media_type: "application/json",
726 });
727 entries.push(flow_entry);
728 }
729
730 Ok((entries, flow_files))
731}
732
733fn apply_summary_component_ids(flow: &mut Flow, summary: &FlowResolveSummaryV1) -> Result<()> {
734 for (node_id, node) in flow.nodes.iter_mut() {
735 let resolved = summary.nodes.get(node_id.as_str()).ok_or_else(|| {
736 anyhow!(
737 "flow resolve summary missing node {} (expected component id for node)",
738 node_id
739 )
740 })?;
741 let summary_id = resolved.component_id.as_str();
742 if node.component.id.as_str().is_empty() || node.component.id.as_str() == "component.exec" {
743 node.component.id = resolved.component_id.clone();
744 continue;
745 }
746 if node.component.id.as_str() != summary_id {
747 anyhow::bail!(
748 "node {} component id {} does not match resolve summary {}",
749 node_id,
750 node.component.id.as_str(),
751 summary_id
752 );
753 }
754 }
755 Ok(())
756}
757
758fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
759 let needs_op = flow.nodes.values().any(|node| {
760 node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
761 });
762 if !needs_op {
763 return Ok(());
764 }
765
766 let flow_doc = load_ygtc_from_path(path)?;
767 let mut operations = BTreeMap::new();
768
769 for (node_id, node_doc) in flow_doc.nodes {
770 let value = serde_json::to_value(&node_doc)
771 .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
772 let normalized = normalize_node_map(value)?;
773 if !normalized.operation.trim().is_empty() {
774 operations.insert(node_id, normalized.operation);
775 }
776 }
777
778 for (node_id, node) in flow.nodes.iter_mut() {
779 if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
780 continue;
781 }
782 if let Some(op) = operations.get(node_id.as_str()) {
783 node.component.operation = Some(op.clone());
784 }
785 }
786
787 Ok(())
788}
789
790fn normalize_legacy_component_exec_ids(flow: &mut Flow) -> Result<()> {
791 for (node_id, node) in flow.nodes.iter_mut() {
792 if node.component.id.as_str() != "component.exec" {
793 continue;
794 }
795 let Some(op) = node.component.operation.as_deref() else {
796 continue;
797 };
798 if !op.contains('.') && !op.contains(':') {
799 continue;
800 }
801 node.component.id = ComponentId::new(op).with_context(|| {
802 format!("invalid component id {} resolved for node {}", op, node_id)
803 })?;
804 node.component.operation = None;
805 }
806 Ok(())
807}
808
809fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
810 let mut deps = Vec::new();
811 let mut seen = BTreeSet::new();
812 for cfg in configs {
813 if !seen.insert(cfg.alias.clone()) {
814 anyhow::bail!("duplicate dependency alias {}", cfg.alias);
815 }
816 deps.push(PackDependency {
817 alias: cfg.alias.clone(),
818 pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
819 version_req: SemverReq::parse(&cfg.version_req)
820 .context("invalid dependency version requirement")?,
821 required_capabilities: cfg.required_capabilities.clone(),
822 });
823 }
824 Ok(deps)
825}
826
827fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
828 let mut assets = Vec::new();
829 for cfg in configs {
830 let logical = cfg
831 .path
832 .strip_prefix(pack_root)
833 .unwrap_or(&cfg.path)
834 .components()
835 .map(|c| c.as_os_str().to_string_lossy().into_owned())
836 .collect::<Vec<_>>()
837 .join("/");
838 if logical.is_empty() {
839 anyhow::bail!("invalid asset path {}", cfg.path.display());
840 }
841 assets.push(AssetFile {
842 logical_path: logical,
843 source: cfg.path.clone(),
844 });
845 }
846 Ok(assets)
847}
848
849fn is_reserved_extra_file(logical_path: &str) -> bool {
850 matches!(logical_path, "sbom.cbor" | "sbom.json")
851}
852
853fn collect_extra_dir_files(pack_root: &Path) -> Result<Vec<ExtraFile>> {
854 let excluded = [
855 "components",
856 "flows",
857 "dist",
858 "target",
859 ".git",
860 ".github",
861 ".idea",
862 ".vscode",
863 "node_modules",
864 ];
865 let mut entries = Vec::new();
866 let mut seen = BTreeSet::new();
867 for entry in fs::read_dir(pack_root)
868 .with_context(|| format!("failed to list pack root {}", pack_root.display()))?
869 {
870 let entry = entry?;
871 let entry_type = entry.file_type()?;
872 let name = entry.file_name();
873 let name = name.to_string_lossy();
874 if entry_type.is_file() {
875 let logical = name.to_string();
876 if is_reserved_extra_file(&logical) {
877 continue;
878 }
879 if !logical.is_empty() && seen.insert(logical.clone()) {
880 entries.push(ExtraFile {
881 logical_path: logical,
882 source: entry.path(),
883 });
884 }
885 continue;
886 }
887 if !entry_type.is_dir() {
888 continue;
889 }
890 if name.starts_with('.') || excluded.contains(&name.as_ref()) {
891 continue;
892 }
893 let root = entry.path();
894 for sub in WalkDir::new(&root)
895 .into_iter()
896 .filter_entry(|walk| !walk.file_name().to_string_lossy().starts_with('.'))
897 .filter_map(Result::ok)
898 {
899 if !sub.file_type().is_file() {
900 continue;
901 }
902 let logical = sub
903 .path()
904 .strip_prefix(pack_root)
905 .unwrap_or(sub.path())
906 .components()
907 .map(|c| c.as_os_str().to_string_lossy().into_owned())
908 .collect::<Vec<_>>()
909 .join("/");
910 if logical.is_empty() || !seen.insert(logical.clone()) {
911 continue;
912 }
913 if is_reserved_extra_file(&logical) {
914 continue;
915 }
916 entries.push(ExtraFile {
917 logical_path: logical,
918 source: sub.path().to_path_buf(),
919 });
920 }
921 }
922 Ok(entries)
923}
924
925fn map_extra_files(
926 extras: &[ExtraFile],
927 asset_paths: &mut BTreeSet<String>,
928 dev_mode: bool,
929 warnings: &mut Vec<String>,
930) -> Vec<(String, PathBuf)> {
931 let mut mapped = Vec::new();
932 for extra in extras {
933 let logical = extra.logical_path.as_str();
934 if logical.starts_with("assets/") {
935 if asset_paths.insert(logical.to_string()) {
936 mapped.push((logical.to_string(), extra.source.clone()));
937 }
938 continue;
939 }
940 if !logical.contains('/') {
941 if is_reserved_source_file(logical) {
942 if dev_mode {
943 mapped.push((logical.to_string(), extra.source.clone()));
944 }
945 continue;
946 }
947 let target = format!("assets/{logical}");
948 if asset_paths.insert(target.clone()) {
949 mapped.push((target, extra.source.clone()));
950 } else {
951 warnings.push(format!(
952 "skipping root asset {logical} because assets/{logical} already exists"
953 ));
954 }
955 continue;
956 }
957 mapped.push((logical.to_string(), extra.source.clone()));
958 }
959 mapped
960}
961
962fn is_reserved_source_file(path: &str) -> bool {
963 matches!(
964 path,
965 "pack.yaml"
966 | "pack.manifest.json"
967 | "pack.lock.json"
968 | "manifest.json"
969 | "manifest.cbor"
970 | "sbom.json"
971 | "sbom.cbor"
972 | "provenance.json"
973 | "secret-requirements.json"
974 | "secrets_requirements.json"
975 ) || path.ends_with(".ygtc")
976}
977
978fn normalize_extensions(
979 extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
980) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
981 extensions.as_ref().filter(|map| !map.is_empty()).cloned()
982}
983
984fn merge_component_manifest_extension(
985 extensions: Option<BTreeMap<String, ExtensionRef>>,
986 manifest_files: &[ComponentManifestFile],
987) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
988 if manifest_files.is_empty() {
989 return Ok(extensions);
990 }
991
992 let entries: Vec<_> = manifest_files
993 .iter()
994 .map(|entry| ComponentManifestIndexEntryV1 {
995 component_id: entry.component_id.clone(),
996 manifest_file: entry.manifest_path.clone(),
997 encoding: ManifestEncoding::Cbor,
998 content_hash: Some(entry.manifest_hash_sha256.clone()),
999 })
1000 .collect();
1001
1002 let index = ComponentManifestIndexV1::new(entries);
1003 let value = index
1004 .to_extension_value()
1005 .context("serialize component manifest index extension")?;
1006
1007 let ext = ExtensionRef {
1008 kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
1009 version: "v1".to_string(),
1010 digest: None,
1011 location: None,
1012 inline: Some(ExtensionInline::Other(value)),
1013 };
1014
1015 let mut map = extensions.unwrap_or_default();
1016 map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
1017 if map.is_empty() {
1018 Ok(None)
1019 } else {
1020 Ok(Some(map))
1021 }
1022}
1023
1024fn merge_component_sources_extension(
1025 extensions: Option<BTreeMap<String, ExtensionRef>>,
1026 lock: &greentic_pack::pack_lock::PackLockV1,
1027 _bundle: BundleMode,
1028 manifest_paths: Option<&std::collections::BTreeMap<String, String>>,
1029) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
1030 let mut entries = Vec::new();
1031 for comp in &lock.components {
1032 if comp.r#ref.starts_with("file://") {
1033 continue;
1034 }
1035 let source = match ComponentSourceRef::from_str(&comp.r#ref) {
1036 Ok(parsed) => parsed,
1037 Err(_) => {
1038 eprintln!(
1039 "warning: skipping pack.lock entry `{}` with unsupported ref {}",
1040 comp.name, comp.r#ref
1041 );
1042 continue;
1043 }
1044 };
1045 let manifest_path = manifest_paths.and_then(|paths| {
1046 comp.component_id
1047 .as_ref()
1048 .map(|id| id.as_str())
1049 .and_then(|key| paths.get(key))
1050 .or_else(|| paths.get(&comp.name))
1051 .cloned()
1052 });
1053 let artifact = if comp.bundled {
1054 let wasm_path = comp.bundled_path.clone().ok_or_else(|| {
1055 anyhow!(
1056 "pack.lock entry {} marked bundled but missing bundled_path",
1057 comp.name
1058 )
1059 })?;
1060 ArtifactLocationV1::Inline {
1061 wasm_path,
1062 manifest_path,
1063 }
1064 } else {
1065 ArtifactLocationV1::Remote
1066 };
1067 entries.push(ComponentSourceEntryV1 {
1068 name: comp.name.clone(),
1069 component_id: comp.component_id.clone(),
1070 source,
1071 resolved: ResolvedComponentV1 {
1072 digest: comp.digest.clone(),
1073 signature: None,
1074 signed_by: None,
1075 },
1076 artifact,
1077 licensing_hint: None,
1078 metering_hint: None,
1079 });
1080 }
1081
1082 if entries.is_empty() {
1083 return Ok(extensions);
1084 }
1085
1086 let payload = ComponentSourcesV1::new(entries)
1087 .to_extension_value()
1088 .context("serialize component_sources extension")?;
1089
1090 let ext = ExtensionRef {
1091 kind: EXT_COMPONENT_SOURCES_V1.to_string(),
1092 version: "v1".to_string(),
1093 digest: None,
1094 location: None,
1095 inline: Some(ExtensionInline::Other(payload)),
1096 };
1097
1098 let mut map = extensions.unwrap_or_default();
1099 map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
1100 if map.is_empty() {
1101 Ok(None)
1102 } else {
1103 Ok(Some(map))
1104 }
1105}
1106
1107fn derive_pack_capabilities(
1108 components: &[(ComponentManifest, ComponentBinary)],
1109) -> Vec<ComponentCapability> {
1110 let mut seen = BTreeSet::new();
1111 let mut caps = Vec::new();
1112
1113 for (component, _) in components {
1114 let mut add = |name: &str| {
1115 if seen.insert(name.to_string()) {
1116 caps.push(ComponentCapability {
1117 name: name.to_string(),
1118 description: None,
1119 });
1120 }
1121 };
1122
1123 if component.capabilities.host.secrets.is_some() {
1124 add("host:secrets");
1125 }
1126 if let Some(state) = &component.capabilities.host.state {
1127 if state.read {
1128 add("host:state:read");
1129 }
1130 if state.write {
1131 add("host:state:write");
1132 }
1133 }
1134 if component.capabilities.host.messaging.is_some() {
1135 add("host:messaging");
1136 }
1137 if component.capabilities.host.events.is_some() {
1138 add("host:events");
1139 }
1140 if component.capabilities.host.http.is_some() {
1141 add("host:http");
1142 }
1143 if component.capabilities.host.telemetry.is_some() {
1144 add("host:telemetry");
1145 }
1146 if component.capabilities.host.iac.is_some() {
1147 add("host:iac");
1148 }
1149 if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
1150 add(&format!(
1151 "wasi:fs:{}",
1152 format!("{:?}", fs.mode).to_lowercase()
1153 ));
1154 if !fs.mounts.is_empty() {
1155 add("wasi:fs:mounts");
1156 }
1157 }
1158 if component.capabilities.wasi.random {
1159 add("wasi:random");
1160 }
1161 if component.capabilities.wasi.clocks {
1162 add("wasi:clocks");
1163 }
1164 }
1165
1166 caps
1167}
1168
1169fn map_kind(raw: &str) -> Result<PackKind> {
1170 match raw.to_ascii_lowercase().as_str() {
1171 "application" => Ok(PackKind::Application),
1172 "provider" => Ok(PackKind::Provider),
1173 "infrastructure" => Ok(PackKind::Infrastructure),
1174 "library" => Ok(PackKind::Library),
1175 other => Err(anyhow!("unknown pack kind {}", other)),
1176 }
1177}
1178
1179fn package_gtpack(
1180 out_path: &Path,
1181 manifest_bytes: &[u8],
1182 build: &BuildProducts,
1183 bundle: BundleMode,
1184 dev_mode: bool,
1185) -> Result<Vec<String>> {
1186 if let Some(parent) = out_path.parent() {
1187 fs::create_dir_all(parent)
1188 .with_context(|| format!("failed to create {}", parent.display()))?;
1189 }
1190
1191 let file = fs::File::create(out_path)
1192 .with_context(|| format!("failed to create {}", out_path.display()))?;
1193 let mut writer = ZipWriter::new(file);
1194 let options = SimpleFileOptions::default()
1195 .compression_method(CompressionMethod::Stored)
1196 .unix_permissions(0o644);
1197
1198 let mut sbom_entries = Vec::new();
1199 let mut written_paths = BTreeSet::new();
1200 let mut warnings = Vec::new();
1201 let mut asset_paths = BTreeSet::new();
1202 record_sbom_entry(
1203 &mut sbom_entries,
1204 "manifest.cbor",
1205 manifest_bytes,
1206 "application/cbor",
1207 );
1208 written_paths.insert("manifest.cbor".to_string());
1209 write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
1210
1211 if dev_mode {
1212 let mut flow_files = build.flow_files.clone();
1213 flow_files.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1214 for flow_file in flow_files {
1215 if written_paths.insert(flow_file.logical_path.clone()) {
1216 record_sbom_entry(
1217 &mut sbom_entries,
1218 &flow_file.logical_path,
1219 &flow_file.bytes,
1220 flow_file.media_type,
1221 );
1222 write_zip_entry(
1223 &mut writer,
1224 &flow_file.logical_path,
1225 &flow_file.bytes,
1226 options,
1227 )?;
1228 }
1229 }
1230 }
1231
1232 let mut component_wasm_paths = BTreeSet::new();
1233 if bundle != BundleMode::None {
1234 for comp in &build.components {
1235 component_wasm_paths.insert(format!("components/{}.wasm", comp.id));
1236 }
1237 }
1238
1239 let mut lock_components = build.lock_components.clone();
1240 lock_components.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
1241 for comp in lock_components {
1242 if component_wasm_paths.contains(&comp.logical_path) {
1243 continue;
1244 }
1245 if !written_paths.insert(comp.logical_path.clone()) {
1246 continue;
1247 }
1248 let bytes = fs::read(&comp.source).with_context(|| {
1249 format!("failed to read cached component {}", comp.source.display())
1250 })?;
1251 record_sbom_entry(
1252 &mut sbom_entries,
1253 &comp.logical_path,
1254 &bytes,
1255 "application/wasm",
1256 );
1257 write_zip_entry(&mut writer, &comp.logical_path, &bytes, options)?;
1258 }
1259
1260 let mut lock_manifests = build.component_manifest_files.clone();
1261 lock_manifests.sort_by(|a, b| a.manifest_path.cmp(&b.manifest_path));
1262 for manifest in lock_manifests {
1263 if written_paths.insert(manifest.manifest_path.clone()) {
1264 record_sbom_entry(
1265 &mut sbom_entries,
1266 &manifest.manifest_path,
1267 &manifest.manifest_bytes,
1268 "application/cbor",
1269 );
1270 write_zip_entry(
1271 &mut writer,
1272 &manifest.manifest_path,
1273 &manifest.manifest_bytes,
1274 options,
1275 )?;
1276 }
1277 }
1278
1279 if bundle != BundleMode::None {
1280 let mut components = build.components.clone();
1281 components.sort_by(|a, b| a.id.cmp(&b.id));
1282 for comp in components {
1283 let logical_wasm = format!("components/{}.wasm", comp.id);
1284 let wasm_bytes = fs::read(&comp.source)
1285 .with_context(|| format!("failed to read component {}", comp.source.display()))?;
1286 if written_paths.insert(logical_wasm.clone()) {
1287 record_sbom_entry(
1288 &mut sbom_entries,
1289 &logical_wasm,
1290 &wasm_bytes,
1291 "application/wasm",
1292 );
1293 write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
1294 }
1295
1296 if written_paths.insert(comp.manifest_path.clone()) {
1297 record_sbom_entry(
1298 &mut sbom_entries,
1299 &comp.manifest_path,
1300 &comp.manifest_bytes,
1301 "application/cbor",
1302 );
1303 write_zip_entry(
1304 &mut writer,
1305 &comp.manifest_path,
1306 &comp.manifest_bytes,
1307 options,
1308 )?;
1309 }
1310 }
1311 }
1312
1313 let mut extra_entries: Vec<_> = Vec::new();
1314 for asset in &build.assets {
1315 let logical = format!("assets/{}", asset.logical_path);
1316 asset_paths.insert(logical.clone());
1317 extra_entries.push((logical, asset.source.clone()));
1318 }
1319 let mut mapped_extra = map_extra_files(
1320 &build.extra_files,
1321 &mut asset_paths,
1322 dev_mode,
1323 &mut warnings,
1324 );
1325 extra_entries.append(&mut mapped_extra);
1326 extra_entries.sort_by(|a, b| a.0.cmp(&b.0));
1327 for (logical, source) in extra_entries {
1328 if !written_paths.insert(logical.clone()) {
1329 continue;
1330 }
1331 let bytes = fs::read(&source)
1332 .with_context(|| format!("failed to read extra file {}", source.display()))?;
1333 record_sbom_entry(
1334 &mut sbom_entries,
1335 &logical,
1336 &bytes,
1337 "application/octet-stream",
1338 );
1339 write_zip_entry(&mut writer, &logical, &bytes, options)?;
1340 }
1341
1342 sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
1343 let sbom_doc = SbomDocument {
1344 format: SBOM_FORMAT.to_string(),
1345 files: sbom_entries,
1346 };
1347 let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
1348 write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
1349
1350 writer
1351 .finish()
1352 .context("failed to finalise gtpack archive")?;
1353 Ok(warnings)
1354}
1355
1356async fn collect_lock_component_artifacts(
1357 lock: &mut greentic_pack::pack_lock::PackLockV1,
1358 runtime: &RuntimeContext,
1359 bundle: BundleMode,
1360 allow_missing: bool,
1361) -> Result<Vec<LockComponentBinary>> {
1362 let dist = DistClient::new(DistOptions {
1363 cache_dir: runtime.cache_dir(),
1364 allow_tags: true,
1365 offline: runtime.network_policy() == NetworkPolicy::Offline,
1366 allow_insecure_local_http: false,
1367 });
1368
1369 let mut artifacts = Vec::new();
1370 let mut seen_paths = BTreeSet::new();
1371 for comp in &mut lock.components {
1372 if comp.r#ref.starts_with("file://") {
1373 comp.bundled = false;
1374 comp.bundled_path = None;
1375 comp.wasm_sha256 = None;
1376 comp.resolved_digest = None;
1377 continue;
1378 }
1379 let parsed = ComponentSourceRef::from_str(&comp.r#ref).ok();
1380 let is_tag = parsed.as_ref().map(|r| r.is_tag()).unwrap_or(false);
1381 let should_bundle = is_tag || bundle == BundleMode::Cache;
1382 if !should_bundle {
1383 comp.bundled = false;
1384 comp.bundled_path = None;
1385 comp.wasm_sha256 = None;
1386 comp.resolved_digest = None;
1387 continue;
1388 }
1389
1390 let resolved = if is_tag {
1391 let item = if runtime.network_policy() == NetworkPolicy::Offline {
1392 dist.ensure_cached(&comp.digest).await.map_err(|err| {
1393 anyhow!(
1394 "tag ref {} must be bundled but cache is missing ({})",
1395 comp.r#ref,
1396 err
1397 )
1398 })?
1399 } else {
1400 dist.resolve_ref(&comp.r#ref)
1401 .await
1402 .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?
1403 };
1404 let cache_path = item.cache_path.clone().ok_or_else(|| {
1405 anyhow!("tag ref {} resolved but cache path is missing", comp.r#ref)
1406 })?;
1407 ResolvedLockItem { item, cache_path }
1408 } else {
1409 let mut resolved = dist
1410 .ensure_cached(&comp.digest)
1411 .await
1412 .ok()
1413 .and_then(|item| item.cache_path.clone().map(|path| (item, path)));
1414 if resolved.is_none()
1415 && runtime.network_policy() != NetworkPolicy::Offline
1416 && !allow_missing
1417 && comp.r#ref.starts_with("oci://")
1418 {
1419 let item = dist
1420 .resolve_ref(&comp.r#ref)
1421 .await
1422 .map_err(|err| anyhow!("failed to resolve {}: {}", comp.r#ref, err))?;
1423 if let Some(path) = item.cache_path.clone() {
1424 resolved = Some((item, path));
1425 }
1426 }
1427 let Some((item, path)) = resolved else {
1428 if runtime.network_policy() == NetworkPolicy::Offline {
1429 anyhow::bail!(
1430 "component {} requires network access ({}) but cache is missing; offline builds cannot download artifacts",
1431 comp.name,
1432 comp.r#ref
1433 );
1434 }
1435 eprintln!(
1436 "warning: component {} is not cached; skipping embed",
1437 comp.name
1438 );
1439 comp.bundled = false;
1440 comp.bundled_path = None;
1441 comp.wasm_sha256 = None;
1442 comp.resolved_digest = None;
1443 continue;
1444 };
1445 ResolvedLockItem {
1446 item,
1447 cache_path: path,
1448 }
1449 };
1450
1451 let cache_path = resolved.cache_path;
1452 let bytes = fs::read(&cache_path)
1453 .with_context(|| format!("failed to read cached component {}", cache_path.display()))?;
1454 let wasm_sha256 = format!("{:x}", Sha256::digest(&bytes));
1455 let logical_path = if is_tag {
1456 format!("blobs/sha256/{}.wasm", wasm_sha256)
1457 } else {
1458 format!("components/{}.wasm", comp.name)
1459 };
1460
1461 comp.bundled = true;
1462 comp.bundled_path = Some(logical_path.clone());
1463 comp.wasm_sha256 = Some(wasm_sha256.clone());
1464 if is_tag {
1465 comp.digest = format!("sha256:{wasm_sha256}");
1466 comp.resolved_digest = Some(resolved.item.digest.clone());
1467 } else {
1468 comp.resolved_digest = None;
1469 }
1470
1471 if seen_paths.insert(logical_path.clone()) {
1472 artifacts.push(LockComponentBinary {
1473 logical_path: logical_path.clone(),
1474 source: cache_path.clone(),
1475 });
1476 }
1477 if let Some(component_id) = comp.component_id.as_ref()
1478 && comp.bundled
1479 {
1480 let alias_path = format!("components/{}.wasm", component_id.as_str());
1481 if alias_path != logical_path && seen_paths.insert(alias_path.clone()) {
1482 artifacts.push(LockComponentBinary {
1483 logical_path: alias_path,
1484 source: cache_path.clone(),
1485 });
1486 }
1487 }
1488 }
1489
1490 Ok(artifacts)
1491}
1492
1493struct ResolvedLockItem {
1494 item: greentic_distributor_client::ResolvedArtifact,
1495 cache_path: PathBuf,
1496}
1497
1498struct MaterializedComponents {
1499 components: Vec<ComponentManifest>,
1500 manifest_files: Vec<ComponentManifestFile>,
1501 manifest_paths: Option<BTreeMap<String, String>>,
1502}
1503
1504fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1505 entries.push(SbomEntry {
1506 path: path.to_string(),
1507 size: bytes.len() as u64,
1508 hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1509 media_type: media_type.to_string(),
1510 });
1511}
1512
1513fn write_zip_entry(
1514 writer: &mut ZipWriter<std::fs::File>,
1515 logical_path: &str,
1516 bytes: &[u8],
1517 options: SimpleFileOptions,
1518) -> Result<()> {
1519 writer
1520 .start_file(logical_path, options)
1521 .with_context(|| format!("failed to start {}", logical_path))?;
1522 writer
1523 .write_all(bytes)
1524 .with_context(|| format!("failed to write {}", logical_path))?;
1525 Ok(())
1526}
1527
1528fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1529 if let Some(parent) = path.parent() {
1530 fs::create_dir_all(parent)
1531 .with_context(|| format!("failed to create directory {}", parent.display()))?;
1532 }
1533 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1534 Ok(())
1535}
1536
1537fn write_stub_wasm(path: &Path) -> Result<()> {
1538 const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1539 write_bytes(path, STUB)
1540}
1541
1542fn collect_component_manifest_files(
1543 components: &[ComponentBinary],
1544 extra: &[ComponentManifestFile],
1545) -> Vec<ComponentManifestFile> {
1546 let mut files: Vec<ComponentManifestFile> = components
1547 .iter()
1548 .map(|binary| ComponentManifestFile {
1549 component_id: binary.id.clone(),
1550 manifest_path: binary.manifest_path.clone(),
1551 manifest_bytes: binary.manifest_bytes.clone(),
1552 manifest_hash_sha256: binary.manifest_hash_sha256.clone(),
1553 })
1554 .collect();
1555 files.extend(extra.iter().cloned());
1556 files.sort_by(|a, b| a.component_id.cmp(&b.component_id));
1557 files.dedup_by(|a, b| a.component_id == b.component_id);
1558 files
1559}
1560
1561fn materialize_flow_components(
1562 pack_dir: &Path,
1563 flows: &[PackFlowEntry],
1564 pack_lock: &greentic_pack::pack_lock::PackLockV1,
1565 components: &[ComponentBinary],
1566 lock_components: &[LockComponentBinary],
1567 require_component_manifests: bool,
1568) -> Result<MaterializedComponents> {
1569 let referenced = collect_flow_component_ids(flows);
1570 if referenced.is_empty() {
1571 return Ok(MaterializedComponents {
1572 components: Vec::new(),
1573 manifest_files: Vec::new(),
1574 manifest_paths: None,
1575 });
1576 }
1577
1578 let mut existing = BTreeSet::new();
1579 for component in components {
1580 existing.insert(component.id.clone());
1581 }
1582
1583 let mut lock_by_id = BTreeMap::new();
1584 let mut lock_by_name = BTreeMap::new();
1585 for entry in &pack_lock.components {
1586 if let Some(component_id) = entry.component_id.as_ref() {
1587 lock_by_id.insert(component_id.as_str().to_string(), entry);
1588 }
1589 lock_by_name.insert(entry.name.clone(), entry);
1590 }
1591
1592 let mut bundle_sources = BTreeMap::new();
1593 for entry in lock_components {
1594 bundle_sources.insert(entry.logical_path.clone(), entry.source.clone());
1595 }
1596
1597 let mut materialized_components = Vec::new();
1598 let mut manifest_files = Vec::new();
1599 let mut manifest_paths: BTreeMap<String, String> = BTreeMap::new();
1600
1601 for component_id in referenced {
1602 if existing.contains(&component_id) {
1603 continue;
1604 }
1605
1606 let lock_entry = lock_by_id
1607 .get(&component_id)
1608 .copied()
1609 .or_else(|| lock_by_name.get(&component_id).copied());
1610 let Some(lock_entry) = lock_entry else {
1611 handle_missing_component_manifest(&component_id, None, require_component_manifests)?;
1612 continue;
1613 };
1614 if !lock_entry.bundled {
1615 if require_component_manifests {
1616 anyhow::bail!(
1617 "component {} is not bundled; cannot materialize manifest without local artifacts",
1618 lock_entry.name
1619 );
1620 }
1621 eprintln!(
1622 "warning: component {} is not bundled; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1623 lock_entry.name
1624 );
1625 continue;
1626 }
1627
1628 let bundled_source = lock_entry
1629 .bundled_path
1630 .as_deref()
1631 .and_then(|path| bundle_sources.get(path));
1632 let manifest = load_component_manifest_for_lock(pack_dir, lock_entry, bundled_source)?;
1633
1634 let Some(manifest) = manifest else {
1635 handle_missing_component_manifest(
1636 &component_id,
1637 Some(&lock_entry.name),
1638 require_component_manifests,
1639 )?;
1640 continue;
1641 };
1642
1643 if let Some(lock_id) = lock_entry.component_id.as_ref()
1644 && manifest.id.as_str() != lock_id.as_str()
1645 {
1646 anyhow::bail!(
1647 "component manifest id {} does not match pack.lock component_id {}",
1648 manifest.id.as_str(),
1649 lock_id.as_str()
1650 );
1651 }
1652
1653 let manifest_file = component_manifest_file_from_manifest(&manifest)?;
1654 manifest_paths.insert(
1655 manifest.id.as_str().to_string(),
1656 manifest_file.manifest_path.clone(),
1657 );
1658 manifest_paths.insert(lock_entry.name.clone(), manifest_file.manifest_path.clone());
1659
1660 materialized_components.push(manifest);
1661 manifest_files.push(manifest_file);
1662 }
1663
1664 let manifest_paths = if manifest_paths.is_empty() {
1665 None
1666 } else {
1667 Some(manifest_paths)
1668 };
1669
1670 Ok(MaterializedComponents {
1671 components: materialized_components,
1672 manifest_files,
1673 manifest_paths,
1674 })
1675}
1676
1677fn collect_flow_component_ids(flows: &[PackFlowEntry]) -> BTreeSet<String> {
1678 let mut ids = BTreeSet::new();
1679 for flow in flows {
1680 for node in flow.flow.nodes.values() {
1681 if node.component.pack_alias.is_some() {
1682 continue;
1683 }
1684 let id = node.component.id.as_str();
1685 if !id.is_empty() {
1686 ids.insert(id.to_string());
1687 }
1688 }
1689 }
1690 ids
1691}
1692
1693fn load_component_manifest_for_lock(
1694 pack_dir: &Path,
1695 lock_entry: &greentic_pack::pack_lock::LockedComponent,
1696 bundled_source: Option<&PathBuf>,
1697) -> Result<Option<ComponentManifest>> {
1698 let mut search_paths = Vec::new();
1699 if let Some(component_id) = lock_entry.component_id.as_ref() {
1700 let id = component_id.as_str();
1701 search_paths.extend(component_manifest_search_paths(pack_dir, id));
1702 }
1703 search_paths.extend(component_manifest_search_paths(pack_dir, &lock_entry.name));
1704 if let Some(source) = bundled_source
1705 && let Some(parent) = source.parent()
1706 {
1707 search_paths.push(parent.join("component.manifest.cbor"));
1708 search_paths.push(parent.join("component.manifest.json"));
1709 }
1710
1711 for path in search_paths {
1712 if path.exists() {
1713 return Ok(Some(load_component_manifest_from_file(&path)?));
1714 }
1715 }
1716
1717 Ok(None)
1718}
1719
1720fn component_manifest_search_paths(pack_dir: &Path, name: &str) -> Vec<PathBuf> {
1721 vec![
1722 pack_dir
1723 .join("components")
1724 .join(format!("{name}.manifest.cbor")),
1725 pack_dir
1726 .join("components")
1727 .join(format!("{name}.manifest.json")),
1728 pack_dir
1729 .join("components")
1730 .join(name)
1731 .join("component.manifest.cbor"),
1732 pack_dir
1733 .join("components")
1734 .join(name)
1735 .join("component.manifest.json"),
1736 ]
1737}
1738
1739fn load_component_manifest_from_file(path: &Path) -> Result<ComponentManifest> {
1740 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
1741 if path
1742 .extension()
1743 .and_then(|ext| ext.to_str())
1744 .is_some_and(|ext| ext.eq_ignore_ascii_case("cbor"))
1745 {
1746 let manifest = serde_cbor::from_slice(&bytes)
1747 .with_context(|| format!("{} is not valid CBOR", path.display()))?;
1748 return Ok(manifest);
1749 }
1750
1751 let manifest = serde_json::from_slice(&bytes)
1752 .with_context(|| format!("{} is not valid JSON", path.display()))?;
1753 Ok(manifest)
1754}
1755
1756fn component_manifest_file_from_manifest(
1757 manifest: &ComponentManifest,
1758) -> Result<ComponentManifestFile> {
1759 let manifest_bytes =
1760 serde_cbor::to_vec(manifest).context("encode component manifest to cbor")?;
1761 let mut sha = Sha256::new();
1762 sha.update(&manifest_bytes);
1763 let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
1764 let manifest_path = format!("components/{}.manifest.cbor", manifest.id.as_str());
1765
1766 Ok(ComponentManifestFile {
1767 component_id: manifest.id.as_str().to_string(),
1768 manifest_path,
1769 manifest_bytes,
1770 manifest_hash_sha256,
1771 })
1772}
1773
1774fn handle_missing_component_manifest(
1775 component_id: &str,
1776 component_name: Option<&str>,
1777 require_component_manifests: bool,
1778) -> Result<()> {
1779 let label = component_name.unwrap_or(component_id);
1780 if require_component_manifests {
1781 anyhow::bail!(
1782 "component manifest metadata missing for {} (supply component.manifest.json or use --require-component-manifests=false)",
1783 label
1784 );
1785 }
1786 eprintln!(
1787 "warning: component manifest metadata missing for {}; pack will emit PACK_COMPONENT_NOT_EXPLICIT",
1788 label
1789 );
1790 Ok(())
1791}
1792
1793fn aggregate_secret_requirements(
1794 components: &[ComponentConfig],
1795 override_path: Option<&Path>,
1796 default_scope: Option<&str>,
1797) -> Result<Vec<SecretRequirement>> {
1798 let default_scope = default_scope.map(parse_default_scope).transpose()?;
1799 let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1800
1801 let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1802 let mut req = req.clone();
1803 if req.scope.is_none() {
1804 if let Some(scope) = default_scope.clone() {
1805 req.scope = Some(scope);
1806 tracing::warn!(
1807 key = %secret_key_string(&req),
1808 source,
1809 "secret requirement missing scope; applying default scope"
1810 );
1811 } else {
1812 anyhow::bail!(
1813 "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1814 secret_key_string(&req),
1815 source
1816 );
1817 }
1818 }
1819 let scope = req.scope.as_ref().expect("scope present");
1820 let fmt = fmt_key(&req);
1821 let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1822 if let Some(existing) = merged.get_mut(&key_tuple) {
1823 merge_requirement(existing, &req);
1824 } else {
1825 merged.insert(key_tuple, req);
1826 }
1827 Ok(())
1828 };
1829
1830 for component in components {
1831 if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1832 for req in &secret_caps.required {
1833 process_req(req, &component.id)?;
1834 }
1835 }
1836 }
1837
1838 if let Some(path) = override_path {
1839 let contents = fs::read_to_string(path)
1840 .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1841 let value: serde_json::Value = if path
1842 .extension()
1843 .and_then(|ext| ext.to_str())
1844 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1845 .unwrap_or(false)
1846 {
1847 let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1848 .with_context(|| format!("{} is not valid YAML", path.display()))?;
1849 serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1850 } else {
1851 serde_json::from_str(&contents)
1852 .with_context(|| format!("{} is not valid JSON", path.display()))?
1853 };
1854
1855 let overrides: Vec<SecretRequirement> =
1856 serde_json::from_value(value).with_context(|| {
1857 format!(
1858 "{} must be an array of secret requirements (migration bridge)",
1859 path.display()
1860 )
1861 })?;
1862 for req in &overrides {
1863 process_req(req, &format!("override:{}", path.display()))?;
1864 }
1865 }
1866
1867 let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1868 out.sort_by(|a, b| {
1869 let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1870 let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1871 (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1872 b_scope,
1873 secret_key_string(b),
1874 fmt_key(b),
1875 ))
1876 });
1877 Ok(out)
1878}
1879
1880fn fmt_key(req: &SecretRequirement) -> String {
1881 req.format
1882 .as_ref()
1883 .map(|f| format!("{:?}", f))
1884 .unwrap_or_else(|| "unspecified".to_string())
1885}
1886
1887fn scope_key(scope: &SecretScope) -> String {
1888 format!(
1889 "{}/{}/{}",
1890 &scope.env,
1891 &scope.tenant,
1892 scope
1893 .team
1894 .as_deref()
1895 .map(|t| t.to_string())
1896 .unwrap_or_else(|| "_".to_string())
1897 )
1898}
1899
1900fn secret_key_string(req: &SecretRequirement) -> String {
1901 let key: String = req.key.clone().into();
1902 key
1903}
1904
1905fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1906 if base.description.is_none() {
1907 base.description = incoming.description.clone();
1908 }
1909 if let Some(schema) = &incoming.schema {
1910 if base.schema.is_none() {
1911 base.schema = Some(schema.clone());
1912 } else if base.schema.as_ref() != Some(schema) {
1913 tracing::warn!(
1914 key = %secret_key_string(base),
1915 "conflicting secret schema encountered; keeping first"
1916 );
1917 }
1918 }
1919
1920 if !incoming.examples.is_empty() {
1921 for example in &incoming.examples {
1922 if !base.examples.contains(example) {
1923 base.examples.push(example.clone());
1924 }
1925 }
1926 }
1927
1928 base.required = base.required || incoming.required;
1929}
1930
1931fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1932 let parts: Vec<_> = raw.split('/').collect();
1933 if parts.len() < 2 || parts.len() > 3 {
1934 anyhow::bail!(
1935 "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1936 raw
1937 );
1938 }
1939 Ok(SecretScope {
1940 env: parts[0].to_string(),
1941 tenant: parts[1].to_string(),
1942 team: parts.get(2).map(|s| s.to_string()),
1943 })
1944}
1945
1946fn write_secret_requirements_file(
1947 pack_root: &Path,
1948 requirements: &[SecretRequirement],
1949 logical_name: &str,
1950) -> Result<PathBuf> {
1951 let path = pack_root.join(".packc").join(logical_name);
1952 if let Some(parent) = path.parent() {
1953 fs::create_dir_all(parent)
1954 .with_context(|| format!("failed to create {}", parent.display()))?;
1955 }
1956 let data = serde_json::to_vec_pretty(&requirements)
1957 .context("failed to serialise secret requirements")?;
1958 fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1959 Ok(path)
1960}
1961
1962fn resolve_secret_requirements_override(
1963 pack_root: &Path,
1964 override_path: Option<&PathBuf>,
1965) -> Option<PathBuf> {
1966 if let Some(path) = override_path {
1967 return Some(path.clone());
1968 }
1969 find_secret_requirements_file(pack_root)
1970}
1971
1972fn find_secret_requirements_file(pack_root: &Path) -> Option<PathBuf> {
1973 for name in ["secrets_requirements.json", "secret-requirements.json"] {
1974 let candidate = pack_root.join(name);
1975 if candidate.is_file() {
1976 return Some(candidate);
1977 }
1978 }
1979 None
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984 use super::*;
1985 use crate::config::BootstrapConfig;
1986 use crate::runtime::resolve_runtime;
1987 use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1988 use greentic_types::flow::FlowKind;
1989 use greentic_types::{ComponentId, decode_pack_manifest};
1990 use serde_json::json;
1991 use sha2::{Digest, Sha256};
1992 use std::collections::BTreeSet;
1993 use std::fs::File;
1994 use std::io::Read;
1995 use std::path::Path;
1996 use std::{fs, path::PathBuf};
1997 use tempfile::tempdir;
1998 use zip::ZipArchive;
1999
2000 #[test]
2001 fn map_kind_accepts_known_values() {
2002 assert!(matches!(
2003 map_kind("application").unwrap(),
2004 PackKind::Application
2005 ));
2006 assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
2007 assert!(matches!(
2008 map_kind("infrastructure").unwrap(),
2009 PackKind::Infrastructure
2010 ));
2011 assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
2012 assert!(map_kind("unknown").is_err());
2013 }
2014
2015 #[test]
2016 fn collect_assets_preserves_relative_paths() {
2017 let root = PathBuf::from("/packs/demo");
2018 let assets = vec![AssetConfig {
2019 path: root.join("assets").join("foo.txt"),
2020 }];
2021 let collected = collect_assets(&assets, &root).expect("collect assets");
2022 assert_eq!(collected[0].logical_path, "assets/foo.txt");
2023 }
2024
2025 fn write_sample_manifest(path: &Path, component_id: &str) {
2026 let manifest: ComponentManifest = serde_json::from_value(json!({
2027 "id": component_id,
2028 "version": "0.1.0",
2029 "supports": [],
2030 "world": "greentic:component/component@0.5.0",
2031 "profiles": { "default": "stateless", "supported": ["stateless"] },
2032 "capabilities": { "wasi": {}, "host": {} },
2033 "operations": [],
2034 "resources": {},
2035 "dev_flows": {}
2036 }))
2037 .expect("manifest");
2038 let bytes = serde_cbor::to_vec(&manifest).expect("encode manifest");
2039 fs::write(path, bytes).expect("write manifest");
2040 }
2041
2042 #[test]
2043 fn load_component_manifest_from_disk_supports_id_specific_files() {
2044 let temp = tempdir().expect("temp dir");
2045 let components = temp.path().join("components");
2046 fs::create_dir_all(&components).expect("create components dir");
2047 let wasm = components.join("component.wasm");
2048 fs::write(&wasm, b"wasm").expect("write wasm");
2049 let manifest_name = components.join("foo.component.manifest.cbor");
2050 write_sample_manifest(&manifest_name, "foo.component");
2051
2052 let manifest =
2053 load_component_manifest_from_disk(&wasm, "foo.component").expect("load manifest");
2054 let manifest = manifest.expect("manifest present");
2055 assert_eq!(manifest.id.to_string(), "foo.component");
2056 }
2057
2058 #[test]
2059 fn load_component_manifest_from_disk_accepts_generic_names() {
2060 let temp = tempdir().expect("temp dir");
2061 let components = temp.path().join("components");
2062 fs::create_dir_all(&components).expect("create components dir");
2063 let wasm = components.join("component.wasm");
2064 fs::write(&wasm, b"wasm").expect("write wasm");
2065 let manifest_name = components.join("component.manifest.cbor");
2066 write_sample_manifest(&manifest_name, "component");
2067
2068 let manifest =
2069 load_component_manifest_from_disk(&wasm, "component").expect("load manifest");
2070 let manifest = manifest.expect("manifest present");
2071 assert_eq!(manifest.id.to_string(), "component");
2072 }
2073
2074 #[test]
2075 fn collect_extra_dir_files_skips_hidden_and_known_dirs() {
2076 let temp = tempdir().expect("temp dir");
2077 let root = temp.path();
2078 fs::create_dir_all(root.join("schemas")).expect("schemas dir");
2079 fs::create_dir_all(root.join("schemas").join(".nested")).expect("nested hidden dir");
2080 fs::create_dir_all(root.join(".hidden")).expect("hidden dir");
2081 fs::create_dir_all(root.join("assets")).expect("assets dir");
2082 fs::write(root.join("README.txt"), b"root").expect("root file");
2083 fs::write(root.join("schemas").join("config.schema.json"), b"{}").expect("schema file");
2084 fs::write(
2085 root.join("schemas").join(".nested").join("skip.json"),
2086 b"{}",
2087 )
2088 .expect("nested hidden file");
2089 fs::write(root.join(".hidden").join("secret.txt"), b"nope").expect("hidden file");
2090 fs::write(root.join("assets").join("asset.txt"), b"nope").expect("asset file");
2091
2092 let collected = collect_extra_dir_files(root).expect("collect extra dirs");
2093 let paths: BTreeSet<_> = collected.iter().map(|e| e.logical_path.as_str()).collect();
2094 assert!(paths.contains("README.txt"));
2095 assert!(paths.contains("schemas/config.schema.json"));
2096 assert!(!paths.contains("schemas/.nested/skip.json"));
2097 assert!(!paths.contains(".hidden/secret.txt"));
2098 assert!(paths.contains("assets/asset.txt"));
2099 }
2100
2101 #[test]
2102 fn collect_extra_dir_files_skips_reserved_sbom_files() {
2103 let temp = tempdir().expect("temp dir");
2104 let root = temp.path();
2105 fs::write(root.join("sbom.cbor"), b"binary").expect("sbom file");
2106 fs::write(root.join("sbom.json"), b"{}").expect("sbom json");
2107 fs::write(root.join("README.md"), b"hello").expect("root file");
2108
2109 let collected = collect_extra_dir_files(root).expect("collect extra dirs");
2110 let paths: BTreeSet<_> = collected.iter().map(|e| e.logical_path.as_str()).collect();
2111 assert!(paths.contains("README.md"));
2112 assert!(!paths.contains("sbom.cbor"));
2113 assert!(!paths.contains("sbom.json"));
2114 }
2115
2116 #[test]
2117 fn build_bootstrap_requires_known_references() {
2118 let config = pack_config_with_bootstrap(BootstrapConfig {
2119 install_flow: Some("flow.a".to_string()),
2120 upgrade_flow: None,
2121 installer_component: Some("component.a".to_string()),
2122 });
2123 let flows = vec![flow_entry("flow.a")];
2124 let components = vec![minimal_component_manifest("component.a")];
2125
2126 let bootstrap = build_bootstrap(&config, &flows, &components)
2127 .expect("bootstrap populated")
2128 .expect("bootstrap present");
2129
2130 assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
2131 assert_eq!(bootstrap.upgrade_flow, None);
2132 assert_eq!(
2133 bootstrap.installer_component.as_deref(),
2134 Some("component.a")
2135 );
2136 }
2137
2138 #[test]
2139 fn build_bootstrap_rejects_unknown_flow() {
2140 let config = pack_config_with_bootstrap(BootstrapConfig {
2141 install_flow: Some("missing".to_string()),
2142 upgrade_flow: None,
2143 installer_component: Some("component.a".to_string()),
2144 });
2145 let flows = vec![flow_entry("flow.a")];
2146 let components = vec![minimal_component_manifest("component.a")];
2147
2148 let err = build_bootstrap(&config, &flows, &components).unwrap_err();
2149 assert!(
2150 err.to_string()
2151 .contains("bootstrap.install_flow references unknown flow"),
2152 "unexpected error: {err}"
2153 );
2154 }
2155
2156 #[test]
2157 fn component_manifest_without_dev_flows_defaults_to_empty() {
2158 let manifest: ComponentManifest = serde_json::from_value(json!({
2159 "id": "component.dev",
2160 "version": "1.0.0",
2161 "supports": ["messaging"],
2162 "world": "greentic:demo@1.0.0",
2163 "profiles": { "default": "default", "supported": ["default"] },
2164 "capabilities": { "wasi": {}, "host": {} },
2165 "operations": [],
2166 "resources": {}
2167 }))
2168 .expect("manifest without dev_flows");
2169
2170 assert!(manifest.dev_flows.is_empty());
2171
2172 let pack_manifest = pack_manifest_with_component(manifest.clone());
2173 let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2174 let decoded: PackManifest =
2175 greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
2176 let stored = decoded
2177 .components
2178 .iter()
2179 .find(|item| item.id == manifest.id)
2180 .expect("component present");
2181 assert!(stored.dev_flows.is_empty());
2182 }
2183
2184 #[test]
2185 fn dev_flows_round_trip_in_manifest_and_gtpack() {
2186 let component = manifest_with_dev_flow();
2187 let pack_manifest = pack_manifest_with_component(component.clone());
2188 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2189
2190 let decoded: PackManifest =
2191 greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
2192 let decoded_component = decoded
2193 .components
2194 .iter()
2195 .find(|item| item.id == component.id)
2196 .expect("component present");
2197 assert_eq!(decoded_component.dev_flows, component.dev_flows);
2198
2199 let temp = tempdir().expect("temp dir");
2200 let wasm_path = temp.path().join("component.wasm");
2201 write_stub_wasm(&wasm_path).expect("write stub wasm");
2202
2203 let build = BuildProducts {
2204 manifest: pack_manifest,
2205 components: vec![ComponentBinary {
2206 id: component.id.to_string(),
2207 source: wasm_path,
2208 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2209 manifest_path: format!("components/{}.manifest.cbor", component.id),
2210 manifest_hash_sha256: {
2211 let mut sha = Sha256::new();
2212 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2213 format!("sha256:{:x}", sha.finalize())
2214 },
2215 }],
2216 lock_components: Vec::new(),
2217 component_manifest_files: Vec::new(),
2218 flow_files: Vec::new(),
2219 assets: Vec::new(),
2220 extra_files: Vec::new(),
2221 };
2222
2223 let out = temp.path().join("demo.gtpack");
2224 let warnings = package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache, false)
2225 .expect("package gtpack");
2226 assert!(warnings.is_empty(), "expected no packaging warnings");
2227
2228 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2229 .expect("read gtpack archive");
2230 let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
2231 let mut stored = Vec::new();
2232 manifest_entry
2233 .read_to_end(&mut stored)
2234 .expect("read manifest");
2235 let decoded: PackManifest =
2236 greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
2237
2238 let stored_component = decoded
2239 .components
2240 .iter()
2241 .find(|item| item.id == component.id)
2242 .expect("component preserved");
2243 assert_eq!(stored_component.dev_flows, component.dev_flows);
2244 }
2245
2246 #[test]
2247 fn prod_gtpack_excludes_forbidden_files() {
2248 let component = manifest_with_dev_flow();
2249 let pack_manifest = pack_manifest_with_component(component.clone());
2250 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2251
2252 let temp = tempdir().expect("temp dir");
2253 let wasm_path = temp.path().join("component.wasm");
2254 write_stub_wasm(&wasm_path).expect("write stub wasm");
2255
2256 let pack_yaml = temp.path().join("pack.yaml");
2257 fs::write(&pack_yaml, "pack").expect("write pack.yaml");
2258 let pack_manifest_json = temp.path().join("pack.manifest.json");
2259 fs::write(&pack_manifest_json, "{}").expect("write manifest json");
2260
2261 let build = BuildProducts {
2262 manifest: pack_manifest,
2263 components: vec![ComponentBinary {
2264 id: component.id.to_string(),
2265 source: wasm_path,
2266 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2267 manifest_path: format!("components/{}.manifest.cbor", component.id),
2268 manifest_hash_sha256: {
2269 let mut sha = Sha256::new();
2270 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2271 format!("sha256:{:x}", sha.finalize())
2272 },
2273 }],
2274 lock_components: Vec::new(),
2275 component_manifest_files: Vec::new(),
2276 flow_files: Vec::new(),
2277 assets: Vec::new(),
2278 extra_files: vec![
2279 ExtraFile {
2280 logical_path: "pack.yaml".to_string(),
2281 source: pack_yaml,
2282 },
2283 ExtraFile {
2284 logical_path: "pack.manifest.json".to_string(),
2285 source: pack_manifest_json,
2286 },
2287 ],
2288 };
2289
2290 let out = temp.path().join("prod.gtpack");
2291 let warnings = package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache, false)
2292 .expect("package gtpack");
2293 assert!(
2294 warnings.is_empty(),
2295 "no warnings expected for forbidden drop"
2296 );
2297
2298 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2299 .expect("read gtpack archive");
2300 assert!(archive.by_name("pack.yaml").is_err());
2301 assert!(archive.by_name("pack.manifest.json").is_err());
2302 }
2303
2304 #[test]
2305 fn asset_mapping_prefers_assets_version_on_conflict() {
2306 let component = manifest_with_dev_flow();
2307 let pack_manifest = pack_manifest_with_component(component.clone());
2308 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2309
2310 let temp = tempdir().expect("temp dir");
2311 let wasm_path = temp.path().join("component.wasm");
2312 write_stub_wasm(&wasm_path).expect("write stub wasm");
2313
2314 let assets_dir = temp.path().join("assets");
2315 fs::create_dir_all(&assets_dir).expect("create assets dir");
2316 let asset_file = assets_dir.join("README.md");
2317 fs::write(&asset_file, "asset").expect("write asset");
2318 let root_asset = temp.path().join("README.md");
2319 fs::write(&root_asset, "root").expect("write root file");
2320
2321 let build = BuildProducts {
2322 manifest: pack_manifest,
2323 components: vec![ComponentBinary {
2324 id: component.id.to_string(),
2325 source: wasm_path,
2326 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2327 manifest_path: format!("components/{}.manifest.cbor", component.id),
2328 manifest_hash_sha256: {
2329 let mut sha = Sha256::new();
2330 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2331 format!("sha256:{:x}", sha.finalize())
2332 },
2333 }],
2334 lock_components: Vec::new(),
2335 component_manifest_files: Vec::new(),
2336 flow_files: Vec::new(),
2337 assets: Vec::new(),
2338 extra_files: vec![
2339 ExtraFile {
2340 logical_path: "assets/README.md".to_string(),
2341 source: asset_file,
2342 },
2343 ExtraFile {
2344 logical_path: "README.md".to_string(),
2345 source: root_asset,
2346 },
2347 ],
2348 };
2349
2350 let out = temp.path().join("conflict.gtpack");
2351 let warnings = package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache, false)
2352 .expect("package gtpack");
2353 assert!(
2354 warnings
2355 .iter()
2356 .any(|w| w.contains("skipping root asset README.md"))
2357 );
2358
2359 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2360 .expect("read gtpack archive");
2361 assert!(archive.by_name("README.md").is_err());
2362 assert!(archive.by_name("assets/README.md").is_ok());
2363 }
2364
2365 #[test]
2366 fn root_files_map_under_assets_directory() {
2367 let component = manifest_with_dev_flow();
2368 let pack_manifest = pack_manifest_with_component(component.clone());
2369 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2370
2371 let temp = tempdir().expect("temp dir");
2372 let wasm_path = temp.path().join("component.wasm");
2373 write_stub_wasm(&wasm_path).expect("write stub wasm");
2374 let root_asset = temp.path().join("notes.txt");
2375 fs::write(&root_asset, "notes").expect("write root asset");
2376
2377 let build = BuildProducts {
2378 manifest: pack_manifest,
2379 components: vec![ComponentBinary {
2380 id: component.id.to_string(),
2381 source: wasm_path,
2382 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2383 manifest_path: format!("components/{}.manifest.cbor", component.id),
2384 manifest_hash_sha256: {
2385 let mut sha = Sha256::new();
2386 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2387 format!("sha256:{:x}", sha.finalize())
2388 },
2389 }],
2390 lock_components: Vec::new(),
2391 component_manifest_files: Vec::new(),
2392 flow_files: Vec::new(),
2393 assets: Vec::new(),
2394 extra_files: vec![ExtraFile {
2395 logical_path: "notes.txt".to_string(),
2396 source: root_asset,
2397 }],
2398 };
2399
2400 let out = temp.path().join("root-assets.gtpack");
2401 let warnings = package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache, false)
2402 .expect("package gtpack");
2403 assert!(
2404 warnings.iter().all(|w| !w.contains("notes.txt")),
2405 "root asset mapping should not warn without conflict"
2406 );
2407
2408 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2409 .expect("read gtpack archive");
2410 assert!(archive.by_name("assets/notes.txt").is_ok());
2411 assert!(archive.by_name("notes.txt").is_err());
2412 }
2413
2414 #[test]
2415 fn prod_gtpack_embeds_secret_requirements_cbor_only() {
2416 let component = manifest_with_dev_flow();
2417 let mut pack_manifest = pack_manifest_with_component(component.clone());
2418 let secret_requirement: SecretRequirement = serde_json::from_value(json!({
2419 "key": "demo/token",
2420 "required": true,
2421 "description": "demo secret",
2422 "scope": { "env": "dev", "tenant": "demo" }
2423 }))
2424 .expect("parse secret requirement");
2425 pack_manifest.secret_requirements = vec![secret_requirement.clone()];
2426 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
2427
2428 let temp = tempdir().expect("temp dir");
2429 let wasm_path = temp.path().join("component.wasm");
2430 write_stub_wasm(&wasm_path).expect("write stub wasm");
2431 let secret_file = temp.path().join("secret-requirements.json");
2432 fs::write(&secret_file, "[{}]").expect("write secret json");
2433
2434 let build = BuildProducts {
2435 manifest: pack_manifest,
2436 components: vec![ComponentBinary {
2437 id: component.id.to_string(),
2438 source: wasm_path,
2439 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
2440 manifest_path: format!("components/{}.manifest.cbor", component.id),
2441 manifest_hash_sha256: {
2442 let mut sha = Sha256::new();
2443 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
2444 format!("sha256:{:x}", sha.finalize())
2445 },
2446 }],
2447 lock_components: Vec::new(),
2448 component_manifest_files: Vec::new(),
2449 flow_files: Vec::new(),
2450 assets: Vec::new(),
2451 extra_files: vec![ExtraFile {
2452 logical_path: "secret-requirements.json".to_string(),
2453 source: secret_file,
2454 }],
2455 };
2456
2457 let out = temp.path().join("secrets.gtpack");
2458 package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache, false)
2459 .expect("package gtpack");
2460
2461 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
2462 .expect("read gtpack archive");
2463 assert!(archive.by_name("secret-requirements.json").is_err());
2464 assert!(archive.by_name("assets/secret-requirements.json").is_err());
2465 assert!(archive.by_name("secrets_requirements.json").is_err());
2466 assert!(archive.by_name("assets/secrets_requirements.json").is_err());
2467
2468 let mut manifest_entry = archive
2469 .by_name("manifest.cbor")
2470 .expect("manifest.cbor present");
2471 let mut manifest_buf = Vec::new();
2472 manifest_entry
2473 .read_to_end(&mut manifest_buf)
2474 .expect("read manifest bytes");
2475 let decoded = decode_pack_manifest(&manifest_buf).expect("decode manifest");
2476 assert_eq!(decoded.secret_requirements, vec![secret_requirement]);
2477 }
2478
2479 #[test]
2480 fn component_sources_extension_respects_bundle() {
2481 let lock_tag = PackLockV1::new(vec![LockedComponent {
2482 name: "demo.tagged".into(),
2483 r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
2484 digest: "sha256:deadbeef".into(),
2485 component_id: None,
2486 bundled: true,
2487 bundled_path: Some("blobs/sha256/deadbeef.wasm".into()),
2488 wasm_sha256: Some("deadbeef".repeat(8)),
2489 resolved_digest: Some("sha256:deadbeef".into()),
2490 }]);
2491
2492 let ext_none = merge_component_sources_extension(None, &lock_tag, BundleMode::None, None)
2493 .expect("ext");
2494 let value = match ext_none
2495 .unwrap()
2496 .get(EXT_COMPONENT_SOURCES_V1)
2497 .and_then(|e| e.inline.as_ref())
2498 {
2499 Some(ExtensionInline::Other(v)) => v.clone(),
2500 _ => panic!("missing inline"),
2501 };
2502 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2503 assert!(matches!(
2504 decoded.components[0].artifact,
2505 ArtifactLocationV1::Inline { .. }
2506 ));
2507
2508 let lock_digest = PackLockV1::new(vec![LockedComponent {
2509 name: "demo.component".into(),
2510 r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
2511 digest: "sha256:deadbeef".into(),
2512 component_id: None,
2513 bundled: false,
2514 bundled_path: None,
2515 wasm_sha256: None,
2516 resolved_digest: None,
2517 }]);
2518
2519 let ext_none =
2520 merge_component_sources_extension(None, &lock_digest, BundleMode::None, None)
2521 .expect("ext");
2522 let value = match ext_none
2523 .unwrap()
2524 .get(EXT_COMPONENT_SOURCES_V1)
2525 .and_then(|e| e.inline.as_ref())
2526 {
2527 Some(ExtensionInline::Other(v)) => v.clone(),
2528 _ => panic!("missing inline"),
2529 };
2530 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2531 assert!(matches!(
2532 decoded.components[0].artifact,
2533 ArtifactLocationV1::Remote
2534 ));
2535
2536 let lock_digest_bundled = PackLockV1::new(vec![LockedComponent {
2537 name: "demo.component".into(),
2538 r#ref: "oci://ghcr.io/demo/component@sha256:deadbeef".into(),
2539 digest: "sha256:deadbeef".into(),
2540 component_id: None,
2541 bundled: true,
2542 bundled_path: Some("components/demo.component.wasm".into()),
2543 wasm_sha256: Some("deadbeef".repeat(8)),
2544 resolved_digest: None,
2545 }]);
2546
2547 let ext_cache =
2548 merge_component_sources_extension(None, &lock_digest_bundled, BundleMode::Cache, None)
2549 .expect("ext");
2550 let value = match ext_cache
2551 .unwrap()
2552 .get(EXT_COMPONENT_SOURCES_V1)
2553 .and_then(|e| e.inline.as_ref())
2554 {
2555 Some(ExtensionInline::Other(v)) => v.clone(),
2556 _ => panic!("missing inline"),
2557 };
2558 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2559 assert!(matches!(
2560 decoded.components[0].artifact,
2561 ArtifactLocationV1::Inline { .. }
2562 ));
2563 }
2564
2565 #[test]
2566 fn component_sources_extension_skips_file_refs() {
2567 let lock = PackLockV1::new(vec![LockedComponent {
2568 name: "local.component".into(),
2569 r#ref: "file:///tmp/component.wasm".into(),
2570 digest: "sha256:deadbeef".into(),
2571 component_id: None,
2572 bundled: false,
2573 bundled_path: None,
2574 wasm_sha256: None,
2575 resolved_digest: None,
2576 }]);
2577
2578 let ext_none =
2579 merge_component_sources_extension(None, &lock, BundleMode::Cache, None).expect("ext");
2580 assert!(ext_none.is_none(), "file refs should be omitted");
2581
2582 let lock = PackLockV1::new(vec![
2583 LockedComponent {
2584 name: "local.component".into(),
2585 r#ref: "file:///tmp/component.wasm".into(),
2586 digest: "sha256:deadbeef".into(),
2587 component_id: None,
2588 bundled: false,
2589 bundled_path: None,
2590 wasm_sha256: None,
2591 resolved_digest: None,
2592 },
2593 LockedComponent {
2594 name: "remote.component".into(),
2595 r#ref: "oci://ghcr.io/demo/component:2.0.0".into(),
2596 digest: "sha256:cafebabe".into(),
2597 component_id: None,
2598 bundled: false,
2599 bundled_path: None,
2600 wasm_sha256: None,
2601 resolved_digest: None,
2602 },
2603 ]);
2604
2605 let ext_some =
2606 merge_component_sources_extension(None, &lock, BundleMode::None, None).expect("ext");
2607 let value = match ext_some
2608 .unwrap()
2609 .get(EXT_COMPONENT_SOURCES_V1)
2610 .and_then(|e| e.inline.as_ref())
2611 {
2612 Some(ExtensionInline::Other(v)) => v.clone(),
2613 _ => panic!("missing inline"),
2614 };
2615 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
2616 assert_eq!(decoded.components.len(), 1);
2617 assert!(matches!(
2618 decoded.components[0].source,
2619 ComponentSourceRef::Oci(_)
2620 ));
2621 }
2622
2623 #[test]
2624 fn build_embeds_lock_components_from_cache() {
2625 let rt = tokio::runtime::Runtime::new().expect("runtime");
2626 rt.block_on(async {
2627 let temp = tempdir().expect("temp dir");
2628 let pack_dir = temp.path().join("pack");
2629 fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2630 fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2631
2632 let wasm_path = pack_dir.join("components/dummy.wasm");
2633 fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2634 .expect("write wasm");
2635
2636 let flow_path = pack_dir.join("flows/main.ygtc");
2637 fs::write(
2638 &flow_path,
2639 r#"id: main
2640type: messaging
2641start: call
2642nodes:
2643 call:
2644 handle_message:
2645 text: "hi"
2646 routing: out
2647"#,
2648 )
2649 .expect("write flow");
2650
2651 let cache_dir = temp.path().join("cache");
2652 let cached_bytes = b"cached-component";
2653 let digest = format!("sha256:{:x}", Sha256::digest(cached_bytes));
2654 let cache_path = cache_dir
2655 .join(digest.trim_start_matches("sha256:"))
2656 .join("component.wasm");
2657 fs::create_dir_all(cache_path.parent().expect("cache parent")).expect("cache dir");
2658 fs::write(&cache_path, cached_bytes).expect("write cached");
2659
2660 let summary = serde_json::json!({
2661 "schema_version": 1,
2662 "flow": "main.ygtc",
2663 "nodes": {
2664 "call": {
2665 "component_id": "dummy.component",
2666 "source": {
2667 "kind": "oci",
2668 "ref": format!("oci://ghcr.io/demo/component@{digest}")
2669 },
2670 "digest": digest
2671 }
2672 }
2673 });
2674 fs::write(
2675 flow_path.with_extension("ygtc.resolve.summary.json"),
2676 serde_json::to_vec_pretty(&summary).expect("summary json"),
2677 )
2678 .expect("write summary");
2679
2680 let pack_yaml = r#"pack_id: demo.lock-bundle
2681version: 0.1.0
2682kind: application
2683publisher: Test
2684components:
2685 - id: dummy.component
2686 version: "0.1.0"
2687 world: "greentic:component/component@0.5.0"
2688 supports: ["messaging"]
2689 profiles:
2690 default: "stateless"
2691 supported: ["stateless"]
2692 capabilities:
2693 wasi: {}
2694 host: {}
2695 operations:
2696 - name: "handle_message"
2697 input_schema: {}
2698 output_schema: {}
2699 wasm: "components/dummy.wasm"
2700flows:
2701 - id: main
2702 file: flows/main.ygtc
2703 tags: [default]
2704 entrypoints: [main]
2705"#;
2706 fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2707
2708 let runtime = crate::runtime::resolve_runtime(
2709 Some(pack_dir.as_path()),
2710 Some(cache_dir.as_path()),
2711 true,
2712 None,
2713 )
2714 .expect("runtime");
2715
2716 let opts = BuildOptions {
2717 pack_dir: pack_dir.clone(),
2718 component_out: None,
2719 manifest_out: pack_dir.join("dist/manifest.cbor"),
2720 sbom_out: None,
2721 gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2722 lock_path: pack_dir.join("pack.lock.json"),
2723 bundle: BundleMode::Cache,
2724 dry_run: false,
2725 secrets_req: None,
2726 default_secret_scope: None,
2727 allow_oci_tags: false,
2728 require_component_manifests: false,
2729 no_extra_dirs: false,
2730 dev: false,
2731 runtime,
2732 skip_update: false,
2733 };
2734
2735 run(&opts).await.expect("build");
2736
2737 let gtpack_path = opts.gtpack_out.expect("gtpack path");
2738 let mut archive = ZipArchive::new(File::open(>pack_path).expect("open gtpack"))
2739 .expect("read gtpack");
2740 assert!(
2741 archive.by_name("components/main___call.wasm").is_ok(),
2742 "missing lock component artifact in gtpack"
2743 );
2744 });
2745 }
2746
2747 #[test]
2748 #[ignore = "requires network access to fetch OCI component"]
2749 fn build_fetches_and_embeds_lock_components_online() {
2750 if std::env::var("GREENTIC_PACK_ONLINE").is_err() {
2751 return;
2752 }
2753 let rt = tokio::runtime::Runtime::new().expect("runtime");
2754 rt.block_on(async {
2755 let temp = tempdir().expect("temp dir");
2756 let pack_dir = temp.path().join("pack");
2757 fs::create_dir_all(pack_dir.join("flows")).expect("flows dir");
2758 fs::create_dir_all(pack_dir.join("components")).expect("components dir");
2759
2760 let wasm_path = pack_dir.join("components/dummy.wasm");
2761 fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])
2762 .expect("write wasm");
2763
2764 let flow_path = pack_dir.join("flows/main.ygtc");
2765 fs::write(
2766 &flow_path,
2767 r#"id: main
2768type: messaging
2769start: call
2770nodes:
2771 call:
2772 handle_message:
2773 text: "hi"
2774 routing: out
2775"#,
2776 )
2777 .expect("write flow");
2778
2779 let digest =
2780 "sha256:0904bee6ecd737506265e3f38f3e4fe6b185c20fd1b0e7c06ce03cdeedc00340";
2781 let summary = serde_json::json!({
2782 "schema_version": 1,
2783 "flow": "main.ygtc",
2784 "nodes": {
2785 "call": {
2786 "component_id": "dummy.component",
2787 "source": {
2788 "kind": "oci",
2789 "ref": format!("oci://ghcr.io/greentic-ai/components/templates@{digest}")
2790 },
2791 "digest": digest
2792 }
2793 }
2794 });
2795 fs::write(
2796 flow_path.with_extension("ygtc.resolve.summary.json"),
2797 serde_json::to_vec_pretty(&summary).expect("summary json"),
2798 )
2799 .expect("write summary");
2800
2801 let pack_yaml = r#"pack_id: demo.lock-online
2802version: 0.1.0
2803kind: application
2804publisher: Test
2805components:
2806 - id: dummy.component
2807 version: "0.1.0"
2808 world: "greentic:component/component@0.5.0"
2809 supports: ["messaging"]
2810 profiles:
2811 default: "stateless"
2812 supported: ["stateless"]
2813 capabilities:
2814 wasi: {}
2815 host: {}
2816 operations:
2817 - name: "handle_message"
2818 input_schema: {}
2819 output_schema: {}
2820 wasm: "components/dummy.wasm"
2821flows:
2822 - id: main
2823 file: flows/main.ygtc
2824 tags: [default]
2825 entrypoints: [main]
2826"#;
2827 fs::write(pack_dir.join("pack.yaml"), pack_yaml).expect("pack.yaml");
2828
2829 let cache_dir = temp.path().join("cache");
2830 let runtime = crate::runtime::resolve_runtime(
2831 Some(pack_dir.as_path()),
2832 Some(cache_dir.as_path()),
2833 false,
2834 None,
2835 )
2836 .expect("runtime");
2837
2838 let opts = BuildOptions {
2839 pack_dir: pack_dir.clone(),
2840 component_out: None,
2841 manifest_out: pack_dir.join("dist/manifest.cbor"),
2842 sbom_out: None,
2843 gtpack_out: Some(pack_dir.join("dist/pack.gtpack")),
2844 lock_path: pack_dir.join("pack.lock.json"),
2845 bundle: BundleMode::Cache,
2846 dry_run: false,
2847 secrets_req: None,
2848 default_secret_scope: None,
2849 allow_oci_tags: false,
2850 require_component_manifests: false,
2851 no_extra_dirs: false,
2852 dev: false,
2853 runtime,
2854 skip_update: false,
2855 };
2856
2857 run(&opts).await.expect("build");
2858
2859 let gtpack_path = opts.gtpack_out.expect("gtpack path");
2860 let mut archive =
2861 ZipArchive::new(File::open(>pack_path).expect("open gtpack"))
2862 .expect("read gtpack");
2863 assert!(
2864 archive.by_name("components/main___call.wasm").is_ok(),
2865 "missing lock component artifact in gtpack"
2866 );
2867 });
2868 }
2869
2870 #[test]
2871 fn aggregate_secret_requirements_dedupes_and_sorts() {
2872 let component: ComponentConfig = serde_json::from_value(json!({
2873 "id": "component.a",
2874 "version": "1.0.0",
2875 "world": "greentic:demo@1.0.0",
2876 "supports": [],
2877 "profiles": { "default": "default", "supported": ["default"] },
2878 "capabilities": {
2879 "wasi": {},
2880 "host": {
2881 "secrets": {
2882 "required": [
2883 {
2884 "key": "db/password",
2885 "required": true,
2886 "scope": { "env": "dev", "tenant": "t1" },
2887 "format": "text",
2888 "description": "primary"
2889 }
2890 ]
2891 }
2892 }
2893 },
2894 "wasm": "component.wasm",
2895 "operations": [],
2896 "resources": {}
2897 }))
2898 .expect("component config");
2899
2900 let dupe: ComponentConfig = serde_json::from_value(json!({
2901 "id": "component.b",
2902 "version": "1.0.0",
2903 "world": "greentic:demo@1.0.0",
2904 "supports": [],
2905 "profiles": { "default": "default", "supported": ["default"] },
2906 "capabilities": {
2907 "wasi": {},
2908 "host": {
2909 "secrets": {
2910 "required": [
2911 {
2912 "key": "db/password",
2913 "required": true,
2914 "scope": { "env": "dev", "tenant": "t1" },
2915 "format": "text",
2916 "description": "secondary",
2917 "examples": ["example"]
2918 }
2919 ]
2920 }
2921 }
2922 },
2923 "wasm": "component.wasm",
2924 "operations": [],
2925 "resources": {}
2926 }))
2927 .expect("component config");
2928
2929 let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
2930 .expect("aggregate secrets");
2931 assert_eq!(reqs.len(), 1);
2932 let req = &reqs[0];
2933 assert_eq!(req.description.as_deref(), Some("primary"));
2934 assert!(req.examples.contains(&"example".to_string()));
2935 }
2936
2937 fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
2938 PackConfig {
2939 pack_id: "demo.pack".to_string(),
2940 version: "1.0.0".to_string(),
2941 kind: "application".to_string(),
2942 publisher: "demo".to_string(),
2943 name: None,
2944 bootstrap: Some(bootstrap),
2945 components: Vec::new(),
2946 dependencies: Vec::new(),
2947 flows: Vec::new(),
2948 assets: Vec::new(),
2949 extensions: None,
2950 }
2951 }
2952
2953 fn flow_entry(id: &str) -> PackFlowEntry {
2954 let flow: Flow = serde_json::from_value(json!({
2955 "schema_version": "flow/v1",
2956 "id": id,
2957 "kind": "messaging"
2958 }))
2959 .expect("flow json");
2960
2961 PackFlowEntry {
2962 id: FlowId::new(id).expect("flow id"),
2963 kind: FlowKind::Messaging,
2964 flow,
2965 tags: Vec::new(),
2966 entrypoints: Vec::new(),
2967 }
2968 }
2969
2970 fn minimal_component_manifest(id: &str) -> ComponentManifest {
2971 serde_json::from_value(json!({
2972 "id": id,
2973 "version": "1.0.0",
2974 "supports": [],
2975 "world": "greentic:demo@1.0.0",
2976 "profiles": { "default": "default", "supported": ["default"] },
2977 "capabilities": { "wasi": {}, "host": {} },
2978 "operations": [],
2979 "resources": {}
2980 }))
2981 .expect("component manifest")
2982 }
2983
2984 fn manifest_with_dev_flow() -> ComponentManifest {
2985 serde_json::from_str(include_str!(
2986 "../tests/fixtures/component_manifest_with_dev_flows.json"
2987 ))
2988 .expect("fixture manifest")
2989 }
2990
2991 fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
2992 let flow = serde_json::from_value(json!({
2993 "schema_version": "flow/v1",
2994 "id": "flow.dev",
2995 "kind": "messaging"
2996 }))
2997 .expect("flow json");
2998
2999 PackManifest {
3000 schema_version: "pack-v1".to_string(),
3001 pack_id: PackId::new("demo.pack").expect("pack id"),
3002 name: None,
3003 version: Version::parse("1.0.0").expect("version"),
3004 kind: PackKind::Application,
3005 publisher: "demo".to_string(),
3006 components: vec![component],
3007 flows: vec![PackFlowEntry {
3008 id: FlowId::new("flow.dev").expect("flow id"),
3009 kind: FlowKind::Messaging,
3010 flow,
3011 tags: Vec::new(),
3012 entrypoints: Vec::new(),
3013 }],
3014 dependencies: Vec::new(),
3015 capabilities: Vec::new(),
3016 secret_requirements: Vec::new(),
3017 signatures: PackSignatures::default(),
3018 bootstrap: None,
3019 extensions: None,
3020 }
3021 }
3022
3023 #[tokio::test]
3024 async fn offline_build_requires_cached_remote_component() {
3025 let temp = tempdir().expect("temp dir");
3026 let cache_dir = temp.path().join("cache");
3027 fs::create_dir_all(&cache_dir).expect("create cache dir");
3028 let project_root = Path::new(env!("CARGO_MANIFEST_DIR"))
3029 .parent()
3030 .expect("workspace root");
3031 let runtime = resolve_runtime(Some(project_root), Some(cache_dir.as_path()), true, None)
3032 .expect("resolve runtime");
3033
3034 let mut lock = PackLockV1::new(vec![LockedComponent {
3035 name: "remote".to_string(),
3036 r#ref: "oci://example/remote@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
3037 digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
3038 .to_string(),
3039 component_id: Some(ComponentId::new("remote.component").expect("valid component id")),
3040 bundled: false,
3041 bundled_path: None,
3042 wasm_sha256: None,
3043 resolved_digest: None,
3044 }]);
3045
3046 let err =
3047 match collect_lock_component_artifacts(&mut lock, &runtime, BundleMode::Cache, false)
3048 .await
3049 {
3050 Ok(_) => panic!("expected offline build to fail without cached component"),
3051 Err(err) => err,
3052 };
3053 let msg = err.to_string();
3054 assert!(
3055 msg.contains("requires network access"),
3056 "error message should describe missing network access, got {}",
3057 msg
3058 );
3059 }
3060}