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