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