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