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