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