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