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