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