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