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