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