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