1use crate::cli::resolve::{self, ResolveArgs};
2use crate::config::{
3 AssetConfig, ComponentConfig, ComponentOperationConfig, FlowConfig, PackConfig,
4};
5use crate::extensions::validate_components_extension;
6use crate::flow_resolve::enforce_sidecar_mappings;
7use crate::runtime::RuntimeContext;
8use anyhow::{Context, Result, anyhow};
9use greentic_flow::add_step::normalize::normalize_node_map;
10use greentic_flow::compile_ygtc_file;
11use greentic_flow::loader::load_ygtc_from_path;
12use greentic_pack::builder::SbomEntry;
13use greentic_pack::pack_lock::read_pack_lock;
14use greentic_types::component_source::ComponentSourceRef;
15use greentic_types::pack::extensions::component_manifests::{
16 ComponentManifestIndexEntryV1, ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
17 ManifestEncoding,
18};
19use greentic_types::pack::extensions::component_sources::{
20 ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
21 ResolvedComponentV1,
22};
23use greentic_types::{
24 BootstrapSpec, ComponentCapability, ComponentConfigurators, ComponentId, ComponentManifest,
25 ComponentOperation, ExtensionInline, ExtensionRef, Flow, FlowId, PackDependency, PackFlowEntry,
26 PackId, PackKind, PackManifest, PackSignatures, SecretRequirement, SecretScope, SemverReq,
27 encode_pack_manifest,
28};
29use semver::Version;
30use serde::Serialize;
31use serde_cbor;
32use serde_yaml_bw::Value as YamlValue;
33use sha2::{Digest, Sha256};
34use std::collections::{BTreeMap, BTreeSet};
35use std::fs;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::str::FromStr;
39use tracing::info;
40use zip::write::SimpleFileOptions;
41use zip::{CompressionMethod, ZipWriter};
42
43const SBOM_FORMAT: &str = "greentic-sbom-v1";
44
45#[derive(Serialize)]
46struct SbomDocument {
47 format: String,
48 files: Vec<SbomEntry>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
52pub enum BundleMode {
53 Cache,
54 None,
55}
56
57#[derive(Clone)]
58pub struct BuildOptions {
59 pub pack_dir: PathBuf,
60 pub component_out: Option<PathBuf>,
61 pub manifest_out: PathBuf,
62 pub sbom_out: Option<PathBuf>,
63 pub gtpack_out: Option<PathBuf>,
64 pub lock_path: PathBuf,
65 pub bundle: BundleMode,
66 pub dry_run: bool,
67 pub secrets_req: Option<PathBuf>,
68 pub default_secret_scope: Option<String>,
69 pub allow_oci_tags: bool,
70 pub runtime: RuntimeContext,
71 pub skip_update: bool,
72}
73
74impl BuildOptions {
75 pub fn from_args(args: crate::BuildArgs, runtime: &RuntimeContext) -> Result<Self> {
76 let pack_dir = args
77 .input
78 .canonicalize()
79 .with_context(|| format!("failed to canonicalize pack dir {}", args.input.display()))?;
80
81 let component_out = args
82 .component_out
83 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
84 let manifest_out = args
85 .manifest
86 .map(|p| if p.is_relative() { pack_dir.join(p) } else { p })
87 .unwrap_or_else(|| pack_dir.join("dist").join("manifest.cbor"));
88 let sbom_out = args
89 .sbom
90 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) });
91 let default_gtpack_name = pack_dir
92 .file_name()
93 .and_then(|name| name.to_str())
94 .unwrap_or("pack");
95 let default_gtpack_out = pack_dir
96 .join("dist")
97 .join(format!("{default_gtpack_name}.gtpack"));
98 let gtpack_out = Some(
99 args.gtpack_out
100 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
101 .unwrap_or(default_gtpack_out),
102 );
103 let lock_path = args
104 .lock
105 .map(|p| if p.is_absolute() { p } else { pack_dir.join(p) })
106 .unwrap_or_else(|| pack_dir.join("pack.lock.json"));
107
108 Ok(Self {
109 pack_dir,
110 component_out,
111 manifest_out,
112 sbom_out,
113 gtpack_out,
114 lock_path,
115 bundle: args.bundle,
116 dry_run: args.dry_run,
117 secrets_req: args.secrets_req,
118 default_secret_scope: args.default_secret_scope,
119 allow_oci_tags: args.allow_oci_tags,
120 runtime: runtime.clone(),
121 skip_update: args.no_update,
122 })
123 }
124}
125
126pub async fn run(opts: &BuildOptions) -> Result<()> {
127 info!(
128 pack_dir = %opts.pack_dir.display(),
129 manifest_out = %opts.manifest_out.display(),
130 gtpack_out = ?opts.gtpack_out,
131 dry_run = opts.dry_run,
132 "building greentic pack"
133 );
134
135 if !opts.skip_update {
136 crate::cli::update::update_pack(&opts.pack_dir, false)?;
138 }
139
140 resolve::handle(
143 ResolveArgs {
144 input: opts.pack_dir.clone(),
145 lock: Some(opts.lock_path.clone()),
146 },
147 &opts.runtime,
148 false,
149 )
150 .await?;
151
152 let config = crate::config::load_pack_config(&opts.pack_dir)?;
153 info!(
154 id = %config.pack_id,
155 version = %config.version,
156 kind = %config.kind,
157 components = config.components.len(),
158 flows = config.flows.len(),
159 dependencies = config.dependencies.len(),
160 "loaded pack.yaml"
161 );
162 validate_components_extension(&config.extensions, opts.allow_oci_tags)?;
163
164 let secret_requirements = aggregate_secret_requirements(
165 &config.components,
166 opts.secrets_req.as_deref(),
167 opts.default_secret_scope.as_deref(),
168 )?;
169
170 if !opts.lock_path.exists() {
171 anyhow::bail!(
172 "pack.lock.json is required (run `greentic-pack resolve`); missing: {}",
173 opts.lock_path.display()
174 );
175 }
176 let pack_lock = read_pack_lock(&opts.lock_path).with_context(|| {
177 format!(
178 "failed to read pack lock {} (try `greentic-pack resolve`)",
179 opts.lock_path.display()
180 )
181 })?;
182
183 let mut build = assemble_manifest(&config, &opts.pack_dir, &secret_requirements)?;
184 build.manifest.extensions =
185 merge_component_sources_extension(build.manifest.extensions, &pack_lock, opts.bundle)?;
186
187 let manifest_bytes = encode_pack_manifest(&build.manifest)?;
188 info!(len = manifest_bytes.len(), "encoded manifest.cbor");
189
190 if opts.dry_run {
191 info!("dry-run complete; no files written");
192 return Ok(());
193 }
194
195 if let Some(component_out) = opts.component_out.as_ref() {
196 write_stub_wasm(component_out)?;
197 }
198
199 write_bytes(&opts.manifest_out, &manifest_bytes)?;
200
201 if let Some(sbom_out) = opts.sbom_out.as_ref() {
202 write_bytes(sbom_out, br#"{"files":[]} "#)?;
203 }
204
205 if let Some(gtpack_out) = opts.gtpack_out.as_ref() {
206 let mut build = build;
207 if !secret_requirements.is_empty() {
208 let logical = "secret-requirements.json".to_string();
209 let req_path =
210 write_secret_requirements_file(&opts.pack_dir, &secret_requirements, &logical)?;
211 build.assets.push(AssetFile {
212 logical_path: logical,
213 source: req_path,
214 });
215 }
216 package_gtpack(gtpack_out, &manifest_bytes, &build, opts.bundle)?;
217 info!(gtpack_out = %gtpack_out.display(), "gtpack archive ready");
218 eprintln!("wrote {}", gtpack_out.display());
219 }
220
221 Ok(())
222}
223
224struct BuildProducts {
225 manifest: PackManifest,
226 components: Vec<ComponentBinary>,
227 flow_files: Vec<FlowFile>,
228 assets: Vec<AssetFile>,
229}
230
231#[derive(Clone)]
232struct ComponentBinary {
233 id: String,
234 source: PathBuf,
235 manifest_bytes: Vec<u8>,
236 manifest_path: String,
237 manifest_hash_sha256: String,
238}
239
240struct AssetFile {
241 logical_path: String,
242 source: PathBuf,
243}
244
245#[derive(Clone)]
246struct FlowFile {
247 logical_path: String,
248 bytes: Vec<u8>,
249 media_type: &'static str,
250}
251
252fn assemble_manifest(
253 config: &PackConfig,
254 pack_root: &Path,
255 secret_requirements: &[SecretRequirement],
256) -> Result<BuildProducts> {
257 let components = build_components(&config.components)?;
258 let component_ids: BTreeSet<String> = config.components.iter().map(|c| c.id.clone()).collect();
259 let (flows, flow_files) = build_flows(&config.flows, &component_ids, pack_root)?;
260 let dependencies = build_dependencies(&config.dependencies)?;
261 let assets = collect_assets(&config.assets, pack_root)?;
262 let component_manifests: Vec<_> = components.iter().map(|c| c.0.clone()).collect();
263 let bootstrap = build_bootstrap(config, &flows, &component_manifests)?;
264 let extensions =
265 merge_component_manifest_extension(normalize_extensions(&config.extensions), &components)?;
266
267 let manifest = PackManifest {
268 schema_version: "pack-v1".to_string(),
269 pack_id: PackId::new(config.pack_id.clone()).context("invalid pack_id")?,
270 version: Version::parse(&config.version)
271 .context("invalid pack version (expected semver)")?,
272 kind: map_kind(&config.kind)?,
273 publisher: config.publisher.clone(),
274 components: component_manifests,
275 flows,
276 dependencies,
277 capabilities: derive_pack_capabilities(&components),
278 secret_requirements: secret_requirements.to_vec(),
279 signatures: PackSignatures::default(),
280 bootstrap,
281 extensions,
282 };
283
284 Ok(BuildProducts {
285 manifest,
286 components: components.into_iter().map(|(_, bin)| bin).collect(),
287 flow_files,
288 assets,
289 })
290}
291
292fn build_components(
293 configs: &[ComponentConfig],
294) -> Result<Vec<(ComponentManifest, ComponentBinary)>> {
295 let mut seen = BTreeSet::new();
296 let mut result = Vec::new();
297
298 for cfg in configs {
299 if !seen.insert(cfg.id.clone()) {
300 anyhow::bail!("duplicate component id {}", cfg.id);
301 }
302
303 info!(id = %cfg.id, wasm = %cfg.wasm.display(), "adding component");
304 let (manifest, binary) = resolve_component_artifacts(cfg)?;
305
306 result.push((manifest, binary));
307 }
308
309 Ok(result)
310}
311
312fn resolve_component_artifacts(
313 cfg: &ComponentConfig,
314) -> Result<(ComponentManifest, ComponentBinary)> {
315 let resolved_wasm = resolve_component_wasm_path(&cfg.wasm)?;
316
317 let mut manifest = if let Some(from_disk) = load_component_manifest_from_disk(&resolved_wasm)? {
318 if from_disk.id.to_string() != cfg.id {
319 anyhow::bail!(
320 "component manifest id {} does not match pack.yaml id {}",
321 from_disk.id,
322 cfg.id
323 );
324 }
325 if from_disk.version.to_string() != cfg.version {
326 anyhow::bail!(
327 "component manifest version {} does not match pack.yaml version {}",
328 from_disk.version,
329 cfg.version
330 );
331 }
332 from_disk
333 } else {
334 manifest_from_config(cfg)?
335 };
336
337 if manifest.operations.is_empty() && !cfg.operations.is_empty() {
339 manifest.operations = cfg
340 .operations
341 .iter()
342 .map(operation_from_config)
343 .collect::<Result<Vec<_>>>()?;
344 }
345
346 let manifest_bytes =
347 serde_cbor::to_vec(&manifest).context("encode component manifest to cbor")?;
348 let mut sha = Sha256::new();
349 sha.update(&manifest_bytes);
350 let manifest_hash_sha256 = format!("sha256:{:x}", sha.finalize());
351 let manifest_path = format!("components/{}.manifest.cbor", cfg.id);
352
353 let binary = ComponentBinary {
354 id: cfg.id.clone(),
355 source: resolved_wasm,
356 manifest_bytes,
357 manifest_path,
358 manifest_hash_sha256,
359 };
360
361 Ok((manifest, binary))
362}
363
364fn manifest_from_config(cfg: &ComponentConfig) -> Result<ComponentManifest> {
365 Ok(ComponentManifest {
366 id: ComponentId::new(cfg.id.clone()).context("invalid component id")?,
367 version: Version::parse(&cfg.version)
368 .context("invalid component version (expected semver)")?,
369 supports: cfg.supports.iter().map(|k| k.to_kind()).collect(),
370 world: cfg.world.clone(),
371 profiles: cfg.profiles.clone(),
372 capabilities: cfg.capabilities.clone(),
373 configurators: convert_configurators(cfg)?,
374 operations: cfg
375 .operations
376 .iter()
377 .map(operation_from_config)
378 .collect::<Result<Vec<_>>>()?,
379 config_schema: cfg.config_schema.clone(),
380 resources: cfg.resources.clone().unwrap_or_default(),
381 dev_flows: BTreeMap::new(),
382 })
383}
384
385fn resolve_component_wasm_path(path: &Path) -> Result<PathBuf> {
386 if path.is_file() {
387 return Ok(path.to_path_buf());
388 }
389 if !path.exists() {
390 anyhow::bail!("component path {} does not exist", path.display());
391 }
392 if !path.is_dir() {
393 anyhow::bail!(
394 "component path {} must be a file or directory",
395 path.display()
396 );
397 }
398
399 let mut component_candidates = Vec::new();
400 let mut wasm_candidates = Vec::new();
401 let mut stack = vec![path.to_path_buf()];
402 while let Some(current) = stack.pop() {
403 for entry in fs::read_dir(¤t)
404 .with_context(|| format!("failed to list components in {}", current.display()))?
405 {
406 let entry = entry?;
407 let entry_type = entry.file_type()?;
408 let entry_path = entry.path();
409 if entry_type.is_dir() {
410 stack.push(entry_path);
411 continue;
412 }
413 if entry_type.is_file() && entry_path.extension() == Some(std::ffi::OsStr::new("wasm"))
414 {
415 let file_name = entry_path
416 .file_name()
417 .and_then(|n| n.to_str())
418 .unwrap_or_default();
419 if file_name.ends_with(".component.wasm") {
420 component_candidates.push(entry_path);
421 } else {
422 wasm_candidates.push(entry_path);
423 }
424 }
425 }
426 }
427
428 let choose = |mut list: Vec<PathBuf>| -> Result<PathBuf> {
429 list.sort();
430 if list.len() == 1 {
431 Ok(list.remove(0))
432 } else {
433 let options = list
434 .iter()
435 .map(|p| p.strip_prefix(path).unwrap_or(p).display().to_string())
436 .collect::<Vec<_>>()
437 .join(", ");
438 anyhow::bail!(
439 "multiple wasm artifacts found under {}: {} (pick a single *.component.wasm or *.wasm)",
440 path.display(),
441 options
442 );
443 }
444 };
445
446 if !component_candidates.is_empty() {
447 return choose(component_candidates);
448 }
449 if !wasm_candidates.is_empty() {
450 return choose(wasm_candidates);
451 }
452
453 anyhow::bail!(
454 "no wasm artifact found under {}; expected *.component.wasm or *.wasm",
455 path.display()
456 );
457}
458
459fn load_component_manifest_from_disk(path: &Path) -> Result<Option<ComponentManifest>> {
460 let manifest_dir = if path.is_dir() {
461 path.to_path_buf()
462 } else {
463 path.parent()
464 .map(Path::to_path_buf)
465 .ok_or_else(|| anyhow!("component path {} has no parent directory", path.display()))?
466 };
467 let manifest_path = manifest_dir.join("component.json");
468 if !manifest_path.exists() {
469 return Ok(None);
470 }
471
472 let manifest: ComponentManifest = serde_json::from_slice(
473 &fs::read(&manifest_path)
474 .with_context(|| format!("failed to read {}", manifest_path.display()))?,
475 )
476 .with_context(|| format!("{} is not a valid component.json", manifest_path.display()))?;
477
478 Ok(Some(manifest))
479}
480
481fn operation_from_config(cfg: &ComponentOperationConfig) -> Result<ComponentOperation> {
482 Ok(ComponentOperation {
483 name: cfg.name.clone(),
484 input_schema: cfg.input_schema.clone(),
485 output_schema: cfg.output_schema.clone(),
486 })
487}
488
489fn convert_configurators(cfg: &ComponentConfig) -> Result<Option<ComponentConfigurators>> {
490 let Some(configurators) = cfg.configurators.as_ref() else {
491 return Ok(None);
492 };
493
494 let basic = match &configurators.basic {
495 Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
496 None => None,
497 };
498 let full = match &configurators.full {
499 Some(id) => Some(FlowId::new(id).context("invalid configurator flow id")?),
500 None => None,
501 };
502
503 Ok(Some(ComponentConfigurators { basic, full }))
504}
505
506fn build_bootstrap(
507 config: &PackConfig,
508 flows: &[PackFlowEntry],
509 components: &[ComponentManifest],
510) -> Result<Option<BootstrapSpec>> {
511 let Some(raw) = config.bootstrap.as_ref() else {
512 return Ok(None);
513 };
514
515 let flow_ids: BTreeSet<_> = flows.iter().map(|flow| flow.id.to_string()).collect();
516 let component_ids: BTreeSet<_> = components.iter().map(|c| c.id.to_string()).collect();
517
518 let mut spec = BootstrapSpec::default();
519
520 if let Some(install_flow) = &raw.install_flow {
521 if !flow_ids.contains(install_flow) {
522 anyhow::bail!(
523 "bootstrap.install_flow references unknown flow {}",
524 install_flow
525 );
526 }
527 spec.install_flow = Some(install_flow.clone());
528 }
529
530 if let Some(upgrade_flow) = &raw.upgrade_flow {
531 if !flow_ids.contains(upgrade_flow) {
532 anyhow::bail!(
533 "bootstrap.upgrade_flow references unknown flow {}",
534 upgrade_flow
535 );
536 }
537 spec.upgrade_flow = Some(upgrade_flow.clone());
538 }
539
540 if let Some(component) = &raw.installer_component {
541 if !component_ids.contains(component) {
542 anyhow::bail!(
543 "bootstrap.installer_component references unknown component {}",
544 component
545 );
546 }
547 spec.installer_component = Some(component.clone());
548 }
549
550 if spec.install_flow.is_none()
551 && spec.upgrade_flow.is_none()
552 && spec.installer_component.is_none()
553 {
554 return Ok(None);
555 }
556
557 Ok(Some(spec))
558}
559
560fn build_flows(
561 configs: &[FlowConfig],
562 component_ids: &BTreeSet<String>,
563 pack_root: &Path,
564) -> Result<(Vec<PackFlowEntry>, Vec<FlowFile>)> {
565 let mut seen = BTreeSet::new();
566 let mut entries = Vec::new();
567 let mut flow_files = Vec::new();
568
569 for cfg in configs {
570 info!(id = %cfg.id, path = %cfg.file.display(), "compiling flow");
571 let yaml_bytes = fs::read(&cfg.file)
572 .with_context(|| format!("failed to read flow {}", cfg.file.display()))?;
573 let mut flow: Flow = compile_ygtc_file(&cfg.file)
574 .with_context(|| format!("failed to compile {}", cfg.file.display()))?;
575 populate_component_exec_operations(&mut flow, &cfg.file).with_context(|| {
576 format!(
577 "failed to resolve component.exec operations in {}",
578 cfg.file.display()
579 )
580 })?;
581 normalize_legacy_component_exec_ids(&mut flow)?;
582 resolve_missing_component_ids(&mut flow, component_ids).with_context(|| {
583 format!("failed to resolve component ids in {}", cfg.file.display())
584 })?;
585 enforce_sidecar_mappings(pack_root, cfg, &flow)?;
586
587 let flow_id = flow.id.to_string();
588 if !seen.insert(flow_id.clone()) {
589 anyhow::bail!("duplicate flow id {}", flow_id);
590 }
591
592 let entrypoints = if cfg.entrypoints.is_empty() {
593 flow.entrypoints.keys().cloned().collect()
594 } else {
595 cfg.entrypoints.clone()
596 };
597
598 let flow_entry = PackFlowEntry {
599 id: flow.id.clone(),
600 kind: flow.kind,
601 flow,
602 tags: cfg.tags.clone(),
603 entrypoints,
604 };
605
606 let flow_id = flow_entry.id.to_string();
607 flow_files.push(FlowFile {
608 logical_path: format!("flows/{flow_id}/flow.ygtc"),
609 bytes: yaml_bytes,
610 media_type: "application/yaml",
611 });
612 flow_files.push(FlowFile {
613 logical_path: format!("flows/{flow_id}/flow.json"),
614 bytes: serde_json::to_vec(&flow_entry.flow).context("encode flow json")?,
615 media_type: "application/json",
616 });
617 entries.push(flow_entry);
618 }
619
620 Ok((entries, flow_files))
621}
622
623fn infer_component_id_for_node(node_id: &str, components: &BTreeSet<String>) -> Result<String> {
624 if components.is_empty() {
625 anyhow::bail!(
626 "node {} is missing component id and no packaged components are available to resolve it",
627 node_id
628 );
629 }
630
631 if components.contains(node_id) {
632 return Ok(node_id.to_string());
633 }
634
635 let suffix_matches: Vec<_> = components
636 .iter()
637 .filter(|candidate| candidate.rsplit('.').next() == Some(node_id))
638 .collect();
639 if suffix_matches.len() == 1 {
640 return Ok((*suffix_matches[0]).clone());
641 }
642
643 if components.len() == 1 {
644 return Ok(components
645 .iter()
646 .next()
647 .expect("component set is non-empty")
648 .clone());
649 }
650
651 anyhow::bail!(
652 "node {} is missing component id and could not be matched to packaged components: {}",
653 node_id,
654 components.iter().cloned().collect::<Vec<_>>().join(", ")
655 );
656}
657
658fn resolve_missing_component_ids(flow: &mut Flow, components: &BTreeSet<String>) -> Result<()> {
659 for (node_id, node) in flow.nodes.iter_mut() {
660 if !node.component.id.as_str().is_empty() && node.component.id.as_str() != "component.exec"
661 {
662 continue;
663 }
664
665 let component_id = infer_component_id_for_node(node_id.as_str(), components)?;
666
667 node.component.id = ComponentId::new(&component_id)
668 .with_context(|| format!("invalid component id resolved for node {}", node_id))?;
669 }
670 Ok(())
671}
672
673fn populate_component_exec_operations(flow: &mut Flow, path: &Path) -> Result<()> {
674 let needs_op = flow.nodes.values().any(|node| {
675 node.component.id.as_str() == "component.exec" && node.component.operation.is_none()
676 });
677 if !needs_op {
678 return Ok(());
679 }
680
681 let flow_doc = load_ygtc_from_path(path)?;
682 let mut operations = BTreeMap::new();
683
684 for (node_id, node_doc) in flow_doc.nodes {
685 let value = serde_json::to_value(&node_doc)
686 .with_context(|| format!("failed to normalize component.exec node {}", node_id))?;
687 let normalized = normalize_node_map(value)?;
688 if !normalized.operation.trim().is_empty() {
689 operations.insert(node_id, normalized.operation);
690 }
691 }
692
693 for (node_id, node) in flow.nodes.iter_mut() {
694 if node.component.id.as_str() != "component.exec" || node.component.operation.is_some() {
695 continue;
696 }
697 if let Some(op) = operations.get(node_id.as_str()) {
698 node.component.operation = Some(op.clone());
699 }
700 }
701
702 Ok(())
703}
704
705fn normalize_legacy_component_exec_ids(flow: &mut Flow) -> Result<()> {
706 for (node_id, node) in flow.nodes.iter_mut() {
707 if node.component.id.as_str() != "component.exec" {
708 continue;
709 }
710 let Some(op) = node.component.operation.as_deref() else {
711 continue;
712 };
713 if !op.contains('.') && !op.contains(':') {
714 continue;
715 }
716 node.component.id = ComponentId::new(op).with_context(|| {
717 format!("invalid component id {} resolved for node {}", op, node_id)
718 })?;
719 node.component.operation = None;
720 }
721 Ok(())
722}
723
724fn build_dependencies(configs: &[crate::config::DependencyConfig]) -> Result<Vec<PackDependency>> {
725 let mut deps = Vec::new();
726 let mut seen = BTreeSet::new();
727 for cfg in configs {
728 if !seen.insert(cfg.alias.clone()) {
729 anyhow::bail!("duplicate dependency alias {}", cfg.alias);
730 }
731 deps.push(PackDependency {
732 alias: cfg.alias.clone(),
733 pack_id: PackId::new(cfg.pack_id.clone()).context("invalid dependency pack_id")?,
734 version_req: SemverReq::parse(&cfg.version_req)
735 .context("invalid dependency version requirement")?,
736 required_capabilities: cfg.required_capabilities.clone(),
737 });
738 }
739 Ok(deps)
740}
741
742fn collect_assets(configs: &[AssetConfig], pack_root: &Path) -> Result<Vec<AssetFile>> {
743 let mut assets = Vec::new();
744 for cfg in configs {
745 let logical = cfg
746 .path
747 .strip_prefix(pack_root)
748 .unwrap_or(&cfg.path)
749 .components()
750 .map(|c| c.as_os_str().to_string_lossy().into_owned())
751 .collect::<Vec<_>>()
752 .join("/");
753 if logical.is_empty() {
754 anyhow::bail!("invalid asset path {}", cfg.path.display());
755 }
756 assets.push(AssetFile {
757 logical_path: logical,
758 source: cfg.path.clone(),
759 });
760 }
761 Ok(assets)
762}
763
764fn normalize_extensions(
765 extensions: &Option<BTreeMap<String, greentic_types::ExtensionRef>>,
766) -> Option<BTreeMap<String, greentic_types::ExtensionRef>> {
767 extensions.as_ref().filter(|map| !map.is_empty()).cloned()
768}
769
770fn merge_component_manifest_extension(
771 extensions: Option<BTreeMap<String, ExtensionRef>>,
772 components: &[(ComponentManifest, ComponentBinary)],
773) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
774 let entries: Vec<_> = components
775 .iter()
776 .map(|(manifest, binary)| ComponentManifestIndexEntryV1 {
777 component_id: manifest.id.to_string(),
778 manifest_file: binary.manifest_path.clone(),
779 encoding: ManifestEncoding::Cbor,
780 content_hash: Some(binary.manifest_hash_sha256.clone()),
781 })
782 .collect();
783
784 let index = ComponentManifestIndexV1::new(entries);
785 let value = index
786 .to_extension_value()
787 .context("serialize component manifest index extension")?;
788
789 let ext = ExtensionRef {
790 kind: EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(),
791 version: "v1".to_string(),
792 digest: None,
793 location: None,
794 inline: Some(ExtensionInline::Other(value)),
795 };
796
797 let mut map = extensions.unwrap_or_default();
798 map.insert(EXT_COMPONENT_MANIFEST_INDEX_V1.to_string(), ext);
799 if map.is_empty() {
800 Ok(None)
801 } else {
802 Ok(Some(map))
803 }
804}
805
806fn merge_component_sources_extension(
807 extensions: Option<BTreeMap<String, ExtensionRef>>,
808 lock: &greentic_pack::pack_lock::PackLockV1,
809 bundle: BundleMode,
810) -> Result<Option<BTreeMap<String, ExtensionRef>>> {
811 let mut entries = Vec::new();
812 for comp in &lock.components {
813 let source = match ComponentSourceRef::from_str(&comp.r#ref) {
814 Ok(parsed) => parsed,
815 Err(_) => {
816 eprintln!(
817 "warning: skipping pack.lock entry `{}` with unsupported ref {}",
818 comp.name, comp.r#ref
819 );
820 continue;
821 }
822 };
823 let artifact = match bundle {
824 BundleMode::None => ArtifactLocationV1::Remote,
825 BundleMode::Cache => ArtifactLocationV1::Inline {
826 wasm_path: format!("components/{}.wasm", comp.name),
827 manifest_path: None,
828 },
829 };
830 entries.push(ComponentSourceEntryV1 {
831 name: comp.name.clone(),
832 component_id: None,
833 source,
834 resolved: ResolvedComponentV1 {
835 digest: comp.digest.clone(),
836 signature: None,
837 signed_by: None,
838 },
839 artifact,
840 licensing_hint: None,
841 metering_hint: None,
842 });
843 }
844
845 if entries.is_empty() {
846 return Ok(extensions);
847 }
848
849 let payload = ComponentSourcesV1::new(entries)
850 .to_extension_value()
851 .context("serialize component_sources extension")?;
852
853 let ext = ExtensionRef {
854 kind: EXT_COMPONENT_SOURCES_V1.to_string(),
855 version: "v1".to_string(),
856 digest: None,
857 location: None,
858 inline: Some(ExtensionInline::Other(payload)),
859 };
860
861 let mut map = extensions.unwrap_or_default();
862 map.insert(EXT_COMPONENT_SOURCES_V1.to_string(), ext);
863 if map.is_empty() {
864 Ok(None)
865 } else {
866 Ok(Some(map))
867 }
868}
869
870fn derive_pack_capabilities(
871 components: &[(ComponentManifest, ComponentBinary)],
872) -> Vec<ComponentCapability> {
873 let mut seen = BTreeSet::new();
874 let mut caps = Vec::new();
875
876 for (component, _) in components {
877 let mut add = |name: &str| {
878 if seen.insert(name.to_string()) {
879 caps.push(ComponentCapability {
880 name: name.to_string(),
881 description: None,
882 });
883 }
884 };
885
886 if component.capabilities.host.secrets.is_some() {
887 add("host:secrets");
888 }
889 if let Some(state) = &component.capabilities.host.state {
890 if state.read {
891 add("host:state:read");
892 }
893 if state.write {
894 add("host:state:write");
895 }
896 }
897 if component.capabilities.host.messaging.is_some() {
898 add("host:messaging");
899 }
900 if component.capabilities.host.events.is_some() {
901 add("host:events");
902 }
903 if component.capabilities.host.http.is_some() {
904 add("host:http");
905 }
906 if component.capabilities.host.telemetry.is_some() {
907 add("host:telemetry");
908 }
909 if component.capabilities.host.iac.is_some() {
910 add("host:iac");
911 }
912 if let Some(fs) = component.capabilities.wasi.filesystem.as_ref() {
913 add(&format!(
914 "wasi:fs:{}",
915 format!("{:?}", fs.mode).to_lowercase()
916 ));
917 if !fs.mounts.is_empty() {
918 add("wasi:fs:mounts");
919 }
920 }
921 if component.capabilities.wasi.random {
922 add("wasi:random");
923 }
924 if component.capabilities.wasi.clocks {
925 add("wasi:clocks");
926 }
927 }
928
929 caps
930}
931
932fn map_kind(raw: &str) -> Result<PackKind> {
933 match raw.to_ascii_lowercase().as_str() {
934 "application" => Ok(PackKind::Application),
935 "provider" => Ok(PackKind::Provider),
936 "infrastructure" => Ok(PackKind::Infrastructure),
937 "library" => Ok(PackKind::Library),
938 other => Err(anyhow!("unknown pack kind {}", other)),
939 }
940}
941
942fn package_gtpack(
943 out_path: &Path,
944 manifest_bytes: &[u8],
945 build: &BuildProducts,
946 bundle: BundleMode,
947) -> Result<()> {
948 if let Some(parent) = out_path.parent() {
949 fs::create_dir_all(parent)
950 .with_context(|| format!("failed to create {}", parent.display()))?;
951 }
952
953 let file = fs::File::create(out_path)
954 .with_context(|| format!("failed to create {}", out_path.display()))?;
955 let mut writer = ZipWriter::new(file);
956 let options = SimpleFileOptions::default()
957 .compression_method(CompressionMethod::Stored)
958 .unix_permissions(0o644);
959
960 let mut sbom_entries = Vec::new();
961 record_sbom_entry(
962 &mut sbom_entries,
963 "manifest.cbor",
964 manifest_bytes,
965 "application/cbor",
966 );
967 write_zip_entry(&mut writer, "manifest.cbor", manifest_bytes, options)?;
968
969 let mut flow_files = build.flow_files.clone();
970 flow_files.sort_by(|a, b| a.logical_path.cmp(&b.logical_path));
971 for flow_file in flow_files {
972 record_sbom_entry(
973 &mut sbom_entries,
974 &flow_file.logical_path,
975 &flow_file.bytes,
976 flow_file.media_type,
977 );
978 write_zip_entry(
979 &mut writer,
980 &flow_file.logical_path,
981 &flow_file.bytes,
982 options,
983 )?;
984 }
985
986 if bundle != BundleMode::None {
987 let mut components = build.components.clone();
988 components.sort_by(|a, b| a.id.cmp(&b.id));
989 for comp in components {
990 let logical_wasm = format!("components/{}.wasm", comp.id);
991 let wasm_bytes = fs::read(&comp.source)
992 .with_context(|| format!("failed to read component {}", comp.source.display()))?;
993 record_sbom_entry(
994 &mut sbom_entries,
995 &logical_wasm,
996 &wasm_bytes,
997 "application/wasm",
998 );
999 write_zip_entry(&mut writer, &logical_wasm, &wasm_bytes, options)?;
1000
1001 record_sbom_entry(
1002 &mut sbom_entries,
1003 &comp.manifest_path,
1004 &comp.manifest_bytes,
1005 "application/cbor",
1006 );
1007 write_zip_entry(
1008 &mut writer,
1009 &comp.manifest_path,
1010 &comp.manifest_bytes,
1011 options,
1012 )?;
1013 }
1014 }
1015
1016 let mut asset_entries: Vec<_> = build
1017 .assets
1018 .iter()
1019 .map(|a| (format!("assets/{}", &a.logical_path), a.source.clone()))
1020 .collect();
1021 asset_entries.sort_by(|a, b| a.0.cmp(&b.0));
1022 for (logical, source) in asset_entries {
1023 let bytes = fs::read(&source)
1024 .with_context(|| format!("failed to read asset {}", source.display()))?;
1025 record_sbom_entry(
1026 &mut sbom_entries,
1027 &logical,
1028 &bytes,
1029 "application/octet-stream",
1030 );
1031 write_zip_entry(&mut writer, &logical, &bytes, options)?;
1032 }
1033
1034 sbom_entries.sort_by(|a, b| a.path.cmp(&b.path));
1035 let sbom_doc = SbomDocument {
1036 format: SBOM_FORMAT.to_string(),
1037 files: sbom_entries,
1038 };
1039 let sbom_bytes = serde_cbor::to_vec(&sbom_doc).context("failed to encode sbom.cbor")?;
1040 write_zip_entry(&mut writer, "sbom.cbor", &sbom_bytes, options)?;
1041
1042 writer
1043 .finish()
1044 .context("failed to finalise gtpack archive")?;
1045 Ok(())
1046}
1047
1048fn record_sbom_entry(entries: &mut Vec<SbomEntry>, path: &str, bytes: &[u8], media_type: &str) {
1049 entries.push(SbomEntry {
1050 path: path.to_string(),
1051 size: bytes.len() as u64,
1052 hash_blake3: blake3::hash(bytes).to_hex().to_string(),
1053 media_type: media_type.to_string(),
1054 });
1055}
1056
1057fn write_zip_entry(
1058 writer: &mut ZipWriter<std::fs::File>,
1059 logical_path: &str,
1060 bytes: &[u8],
1061 options: SimpleFileOptions,
1062) -> Result<()> {
1063 writer
1064 .start_file(logical_path, options)
1065 .with_context(|| format!("failed to start {}", logical_path))?;
1066 writer
1067 .write_all(bytes)
1068 .with_context(|| format!("failed to write {}", logical_path))?;
1069 Ok(())
1070}
1071
1072fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1073 if let Some(parent) = path.parent() {
1074 fs::create_dir_all(parent)
1075 .with_context(|| format!("failed to create directory {}", parent.display()))?;
1076 }
1077 fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
1078 Ok(())
1079}
1080
1081fn write_stub_wasm(path: &Path) -> Result<()> {
1082 const STUB: &[u8] = &[0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
1083 write_bytes(path, STUB)
1084}
1085
1086fn aggregate_secret_requirements(
1087 components: &[ComponentConfig],
1088 override_path: Option<&Path>,
1089 default_scope: Option<&str>,
1090) -> Result<Vec<SecretRequirement>> {
1091 let default_scope = default_scope.map(parse_default_scope).transpose()?;
1092 let mut merged: BTreeMap<(String, String, String), SecretRequirement> = BTreeMap::new();
1093
1094 let mut process_req = |req: &SecretRequirement, source: &str| -> Result<()> {
1095 let mut req = req.clone();
1096 if req.scope.is_none() {
1097 if let Some(scope) = default_scope.clone() {
1098 req.scope = Some(scope);
1099 tracing::warn!(
1100 key = %secret_key_string(&req),
1101 source,
1102 "secret requirement missing scope; applying default scope"
1103 );
1104 } else {
1105 anyhow::bail!(
1106 "secret requirement {} from {} is missing scope (provide --default-secret-scope or fix the component manifest)",
1107 secret_key_string(&req),
1108 source
1109 );
1110 }
1111 }
1112 let scope = req.scope.as_ref().expect("scope present");
1113 let fmt = fmt_key(&req);
1114 let key_tuple = (req.key.clone().into(), scope_key(scope), fmt.clone());
1115 if let Some(existing) = merged.get_mut(&key_tuple) {
1116 merge_requirement(existing, &req);
1117 } else {
1118 merged.insert(key_tuple, req);
1119 }
1120 Ok(())
1121 };
1122
1123 for component in components {
1124 if let Some(secret_caps) = component.capabilities.host.secrets.as_ref() {
1125 for req in &secret_caps.required {
1126 process_req(req, &component.id)?;
1127 }
1128 }
1129 }
1130
1131 if let Some(path) = override_path {
1132 let contents = fs::read_to_string(path)
1133 .with_context(|| format!("failed to read secrets override {}", path.display()))?;
1134 let value: serde_json::Value = if path
1135 .extension()
1136 .and_then(|ext| ext.to_str())
1137 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
1138 .unwrap_or(false)
1139 {
1140 let yaml: YamlValue = serde_yaml_bw::from_str(&contents)
1141 .with_context(|| format!("{} is not valid YAML", path.display()))?;
1142 serde_json::to_value(yaml).context("failed to normalise YAML secrets override")?
1143 } else {
1144 serde_json::from_str(&contents)
1145 .with_context(|| format!("{} is not valid JSON", path.display()))?
1146 };
1147
1148 let overrides: Vec<SecretRequirement> =
1149 serde_json::from_value(value).with_context(|| {
1150 format!(
1151 "{} must be an array of secret requirements (migration bridge)",
1152 path.display()
1153 )
1154 })?;
1155 for req in &overrides {
1156 process_req(req, &format!("override:{}", path.display()))?;
1157 }
1158 }
1159
1160 let mut out: Vec<SecretRequirement> = merged.into_values().collect();
1161 out.sort_by(|a, b| {
1162 let a_scope = a.scope.as_ref().map(scope_key).unwrap_or_default();
1163 let b_scope = b.scope.as_ref().map(scope_key).unwrap_or_default();
1164 (a_scope, secret_key_string(a), fmt_key(a)).cmp(&(
1165 b_scope,
1166 secret_key_string(b),
1167 fmt_key(b),
1168 ))
1169 });
1170 Ok(out)
1171}
1172
1173fn fmt_key(req: &SecretRequirement) -> String {
1174 req.format
1175 .as_ref()
1176 .map(|f| format!("{:?}", f))
1177 .unwrap_or_else(|| "unspecified".to_string())
1178}
1179
1180fn scope_key(scope: &SecretScope) -> String {
1181 format!(
1182 "{}/{}/{}",
1183 &scope.env,
1184 &scope.tenant,
1185 scope
1186 .team
1187 .as_deref()
1188 .map(|t| t.to_string())
1189 .unwrap_or_else(|| "_".to_string())
1190 )
1191}
1192
1193fn secret_key_string(req: &SecretRequirement) -> String {
1194 let key: String = req.key.clone().into();
1195 key
1196}
1197
1198fn merge_requirement(base: &mut SecretRequirement, incoming: &SecretRequirement) {
1199 if base.description.is_none() {
1200 base.description = incoming.description.clone();
1201 }
1202 if let Some(schema) = &incoming.schema {
1203 if base.schema.is_none() {
1204 base.schema = Some(schema.clone());
1205 } else if base.schema.as_ref() != Some(schema) {
1206 tracing::warn!(
1207 key = %secret_key_string(base),
1208 "conflicting secret schema encountered; keeping first"
1209 );
1210 }
1211 }
1212
1213 if !incoming.examples.is_empty() {
1214 for example in &incoming.examples {
1215 if !base.examples.contains(example) {
1216 base.examples.push(example.clone());
1217 }
1218 }
1219 }
1220
1221 base.required = base.required || incoming.required;
1222}
1223
1224fn parse_default_scope(raw: &str) -> Result<SecretScope> {
1225 let parts: Vec<_> = raw.split('/').collect();
1226 if parts.len() < 2 || parts.len() > 3 {
1227 anyhow::bail!(
1228 "default secret scope must be ENV/TENANT or ENV/TENANT/TEAM (got {})",
1229 raw
1230 );
1231 }
1232 Ok(SecretScope {
1233 env: parts[0].to_string(),
1234 tenant: parts[1].to_string(),
1235 team: parts.get(2).map(|s| s.to_string()),
1236 })
1237}
1238
1239fn write_secret_requirements_file(
1240 pack_root: &Path,
1241 requirements: &[SecretRequirement],
1242 logical_name: &str,
1243) -> Result<PathBuf> {
1244 let path = pack_root.join(".packc").join(logical_name);
1245 if let Some(parent) = path.parent() {
1246 fs::create_dir_all(parent)
1247 .with_context(|| format!("failed to create {}", parent.display()))?;
1248 }
1249 let data = serde_json::to_vec_pretty(&requirements)
1250 .context("failed to serialise secret requirements")?;
1251 fs::write(&path, data).with_context(|| format!("failed to write {}", path.display()))?;
1252 Ok(path)
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257 use super::*;
1258 use crate::config::BootstrapConfig;
1259 use greentic_pack::pack_lock::{LockedComponent, PackLockV1};
1260 use greentic_types::flow::FlowKind;
1261 use serde_json::json;
1262 use std::io::Read;
1263 use std::{fs, path::PathBuf};
1264 use tempfile::tempdir;
1265 use zip::ZipArchive;
1266
1267 #[test]
1268 fn map_kind_accepts_known_values() {
1269 assert!(matches!(
1270 map_kind("application").unwrap(),
1271 PackKind::Application
1272 ));
1273 assert!(matches!(map_kind("provider").unwrap(), PackKind::Provider));
1274 assert!(matches!(
1275 map_kind("infrastructure").unwrap(),
1276 PackKind::Infrastructure
1277 ));
1278 assert!(matches!(map_kind("library").unwrap(), PackKind::Library));
1279 assert!(map_kind("unknown").is_err());
1280 }
1281
1282 #[test]
1283 fn collect_assets_preserves_relative_paths() {
1284 let root = PathBuf::from("/packs/demo");
1285 let assets = vec![AssetConfig {
1286 path: root.join("assets").join("foo.txt"),
1287 }];
1288 let collected = collect_assets(&assets, &root).expect("collect assets");
1289 assert_eq!(collected[0].logical_path, "assets/foo.txt");
1290 }
1291
1292 #[test]
1293 fn build_bootstrap_requires_known_references() {
1294 let config = pack_config_with_bootstrap(BootstrapConfig {
1295 install_flow: Some("flow.a".to_string()),
1296 upgrade_flow: None,
1297 installer_component: Some("component.a".to_string()),
1298 });
1299 let flows = vec![flow_entry("flow.a")];
1300 let components = vec![minimal_component_manifest("component.a")];
1301
1302 let bootstrap = build_bootstrap(&config, &flows, &components)
1303 .expect("bootstrap populated")
1304 .expect("bootstrap present");
1305
1306 assert_eq!(bootstrap.install_flow.as_deref(), Some("flow.a"));
1307 assert_eq!(bootstrap.upgrade_flow, None);
1308 assert_eq!(
1309 bootstrap.installer_component.as_deref(),
1310 Some("component.a")
1311 );
1312 }
1313
1314 #[test]
1315 fn build_bootstrap_rejects_unknown_flow() {
1316 let config = pack_config_with_bootstrap(BootstrapConfig {
1317 install_flow: Some("missing".to_string()),
1318 upgrade_flow: None,
1319 installer_component: Some("component.a".to_string()),
1320 });
1321 let flows = vec![flow_entry("flow.a")];
1322 let components = vec![minimal_component_manifest("component.a")];
1323
1324 let err = build_bootstrap(&config, &flows, &components).unwrap_err();
1325 assert!(
1326 err.to_string()
1327 .contains("bootstrap.install_flow references unknown flow"),
1328 "unexpected error: {err}"
1329 );
1330 }
1331
1332 #[test]
1333 fn component_manifest_without_dev_flows_defaults_to_empty() {
1334 let manifest: ComponentManifest = serde_json::from_value(json!({
1335 "id": "component.dev",
1336 "version": "1.0.0",
1337 "supports": ["messaging"],
1338 "world": "greentic:demo@1.0.0",
1339 "profiles": { "default": "default", "supported": ["default"] },
1340 "capabilities": { "wasi": {}, "host": {} },
1341 "operations": [],
1342 "resources": {}
1343 }))
1344 .expect("manifest without dev_flows");
1345
1346 assert!(manifest.dev_flows.is_empty());
1347
1348 let pack_manifest = pack_manifest_with_component(manifest.clone());
1349 let encoded = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1350 let decoded: PackManifest =
1351 greentic_types::decode_pack_manifest(&encoded).expect("decode manifest");
1352 let stored = decoded
1353 .components
1354 .iter()
1355 .find(|item| item.id == manifest.id)
1356 .expect("component present");
1357 assert!(stored.dev_flows.is_empty());
1358 }
1359
1360 #[test]
1361 fn dev_flows_round_trip_in_manifest_and_gtpack() {
1362 let component = manifest_with_dev_flow();
1363 let pack_manifest = pack_manifest_with_component(component.clone());
1364 let manifest_bytes = encode_pack_manifest(&pack_manifest).expect("encode manifest");
1365
1366 let decoded: PackManifest =
1367 greentic_types::decode_pack_manifest(&manifest_bytes).expect("decode manifest");
1368 let decoded_component = decoded
1369 .components
1370 .iter()
1371 .find(|item| item.id == component.id)
1372 .expect("component present");
1373 assert_eq!(decoded_component.dev_flows, component.dev_flows);
1374
1375 let temp = tempdir().expect("temp dir");
1376 let wasm_path = temp.path().join("component.wasm");
1377 write_stub_wasm(&wasm_path).expect("write stub wasm");
1378
1379 let build = BuildProducts {
1380 manifest: pack_manifest,
1381 components: vec![ComponentBinary {
1382 id: component.id.to_string(),
1383 source: wasm_path,
1384 manifest_bytes: serde_cbor::to_vec(&component).expect("component cbor"),
1385 manifest_path: format!("components/{}.manifest.cbor", component.id),
1386 manifest_hash_sha256: {
1387 let mut sha = Sha256::new();
1388 sha.update(serde_cbor::to_vec(&component).expect("component cbor"));
1389 format!("sha256:{:x}", sha.finalize())
1390 },
1391 }],
1392 flow_files: Vec::new(),
1393 assets: Vec::new(),
1394 };
1395
1396 let out = temp.path().join("demo.gtpack");
1397 package_gtpack(&out, &manifest_bytes, &build, BundleMode::Cache).expect("package gtpack");
1398
1399 let mut archive = ZipArchive::new(fs::File::open(&out).expect("open gtpack"))
1400 .expect("read gtpack archive");
1401 let mut manifest_entry = archive.by_name("manifest.cbor").expect("manifest.cbor");
1402 let mut stored = Vec::new();
1403 manifest_entry
1404 .read_to_end(&mut stored)
1405 .expect("read manifest");
1406 let decoded: PackManifest =
1407 greentic_types::decode_pack_manifest(&stored).expect("decode packaged manifest");
1408
1409 let stored_component = decoded
1410 .components
1411 .iter()
1412 .find(|item| item.id == component.id)
1413 .expect("component preserved");
1414 assert_eq!(stored_component.dev_flows, component.dev_flows);
1415 }
1416
1417 #[test]
1418 fn component_sources_extension_respects_bundle() {
1419 let lock = PackLockV1::new(vec![LockedComponent {
1420 name: "demo.component".into(),
1421 r#ref: "oci://ghcr.io/demo/component:1.0.0".into(),
1422 digest: "sha256:deadbeef".into(),
1423 }]);
1424
1425 let ext_none =
1426 merge_component_sources_extension(None, &lock, BundleMode::None).expect("ext");
1427 let value = match ext_none
1428 .unwrap()
1429 .get(EXT_COMPONENT_SOURCES_V1)
1430 .and_then(|e| e.inline.as_ref())
1431 {
1432 Some(ExtensionInline::Other(v)) => v.clone(),
1433 _ => panic!("missing inline"),
1434 };
1435 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1436 assert!(matches!(
1437 decoded.components[0].artifact,
1438 ArtifactLocationV1::Remote
1439 ));
1440
1441 let ext_cache =
1442 merge_component_sources_extension(None, &lock, BundleMode::Cache).expect("ext");
1443 let value = match ext_cache
1444 .unwrap()
1445 .get(EXT_COMPONENT_SOURCES_V1)
1446 .and_then(|e| e.inline.as_ref())
1447 {
1448 Some(ExtensionInline::Other(v)) => v.clone(),
1449 _ => panic!("missing inline"),
1450 };
1451 let decoded = ComponentSourcesV1::from_extension_value(&value).expect("decode");
1452 assert!(matches!(
1453 decoded.components[0].artifact,
1454 ArtifactLocationV1::Inline { .. }
1455 ));
1456 }
1457
1458 #[test]
1459 fn aggregate_secret_requirements_dedupes_and_sorts() {
1460 let component: ComponentConfig = serde_json::from_value(json!({
1461 "id": "component.a",
1462 "version": "1.0.0",
1463 "world": "greentic:demo@1.0.0",
1464 "supports": [],
1465 "profiles": { "default": "default", "supported": ["default"] },
1466 "capabilities": {
1467 "wasi": {},
1468 "host": {
1469 "secrets": {
1470 "required": [
1471 {
1472 "key": "db/password",
1473 "required": true,
1474 "scope": { "env": "dev", "tenant": "t1" },
1475 "format": "text",
1476 "description": "primary"
1477 }
1478 ]
1479 }
1480 }
1481 },
1482 "wasm": "component.wasm",
1483 "operations": [],
1484 "resources": {}
1485 }))
1486 .expect("component config");
1487
1488 let dupe: ComponentConfig = serde_json::from_value(json!({
1489 "id": "component.b",
1490 "version": "1.0.0",
1491 "world": "greentic:demo@1.0.0",
1492 "supports": [],
1493 "profiles": { "default": "default", "supported": ["default"] },
1494 "capabilities": {
1495 "wasi": {},
1496 "host": {
1497 "secrets": {
1498 "required": [
1499 {
1500 "key": "db/password",
1501 "required": true,
1502 "scope": { "env": "dev", "tenant": "t1" },
1503 "format": "text",
1504 "description": "secondary",
1505 "examples": ["example"]
1506 }
1507 ]
1508 }
1509 }
1510 },
1511 "wasm": "component.wasm",
1512 "operations": [],
1513 "resources": {}
1514 }))
1515 .expect("component config");
1516
1517 let reqs = aggregate_secret_requirements(&[component, dupe], None, None)
1518 .expect("aggregate secrets");
1519 assert_eq!(reqs.len(), 1);
1520 let req = &reqs[0];
1521 assert_eq!(req.description.as_deref(), Some("primary"));
1522 assert!(req.examples.contains(&"example".to_string()));
1523 }
1524
1525 fn pack_config_with_bootstrap(bootstrap: BootstrapConfig) -> PackConfig {
1526 PackConfig {
1527 pack_id: "demo.pack".to_string(),
1528 version: "1.0.0".to_string(),
1529 kind: "application".to_string(),
1530 publisher: "demo".to_string(),
1531 bootstrap: Some(bootstrap),
1532 components: Vec::new(),
1533 dependencies: Vec::new(),
1534 flows: Vec::new(),
1535 assets: Vec::new(),
1536 extensions: None,
1537 }
1538 }
1539
1540 fn flow_entry(id: &str) -> PackFlowEntry {
1541 let flow: Flow = serde_json::from_value(json!({
1542 "schema_version": "flow/v1",
1543 "id": id,
1544 "kind": "messaging"
1545 }))
1546 .expect("flow json");
1547
1548 PackFlowEntry {
1549 id: FlowId::new(id).expect("flow id"),
1550 kind: FlowKind::Messaging,
1551 flow,
1552 tags: Vec::new(),
1553 entrypoints: Vec::new(),
1554 }
1555 }
1556
1557 fn minimal_component_manifest(id: &str) -> ComponentManifest {
1558 serde_json::from_value(json!({
1559 "id": id,
1560 "version": "1.0.0",
1561 "supports": [],
1562 "world": "greentic:demo@1.0.0",
1563 "profiles": { "default": "default", "supported": ["default"] },
1564 "capabilities": { "wasi": {}, "host": {} },
1565 "operations": [],
1566 "resources": {}
1567 }))
1568 .expect("component manifest")
1569 }
1570
1571 fn manifest_with_dev_flow() -> ComponentManifest {
1572 serde_json::from_str(include_str!(
1573 "../tests/fixtures/component_manifest_with_dev_flows.json"
1574 ))
1575 .expect("fixture manifest")
1576 }
1577
1578 fn pack_manifest_with_component(component: ComponentManifest) -> PackManifest {
1579 let flow = serde_json::from_value(json!({
1580 "schema_version": "flow/v1",
1581 "id": "flow.dev",
1582 "kind": "messaging"
1583 }))
1584 .expect("flow json");
1585
1586 PackManifest {
1587 schema_version: "pack-v1".to_string(),
1588 pack_id: PackId::new("demo.pack").expect("pack id"),
1589 version: Version::parse("1.0.0").expect("version"),
1590 kind: PackKind::Application,
1591 publisher: "demo".to_string(),
1592 components: vec![component],
1593 flows: vec![PackFlowEntry {
1594 id: FlowId::new("flow.dev").expect("flow id"),
1595 kind: FlowKind::Messaging,
1596 flow,
1597 tags: Vec::new(),
1598 entrypoints: Vec::new(),
1599 }],
1600 dependencies: Vec::new(),
1601 capabilities: Vec::new(),
1602 secret_requirements: Vec::new(),
1603 signatures: PackSignatures::default(),
1604 bootstrap: None,
1605 extensions: None,
1606 }
1607 }
1608}