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