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