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