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