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