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