1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use anyhow::{Context, Result, bail};
7use greentic_flow::flow_bundle::load_and_validate_bundle;
8use greentic_pack::builder::{
9 ComponentArtifact, ComponentPin as PackComponentPin, FlowBundle as PackFlowBundle, ImportRef,
10 NodeRef as PackNodeRef, PACK_VERSION, PackBuilder, PackMeta, Provenance, Signing,
11};
12use greentic_pack::events::EventsSection;
13use greentic_pack::messaging::MessagingSection;
14use greentic_pack::repo::{InterfaceBinding, RepoPackSection};
15use greentic_types::PackKind;
16use semver::Version;
17use serde::Deserialize;
18use serde_json::{Value as JsonValue, json};
19use time::OffsetDateTime;
20use time::format_description::well_known::Rfc3339;
21
22use crate::component_resolver::{
23 ComponentResolver, NodeSchemaError, ResolvedComponent, ResolvedNode,
24};
25
26#[derive(Debug, Clone, Copy)]
27pub enum PackSigning {
28 Dev,
29 None,
30}
31
32impl From<PackSigning> for Signing {
33 fn from(value: PackSigning) -> Self {
34 match value {
35 PackSigning::Dev => Signing::Dev,
36 PackSigning::None => Signing::None,
37 }
38 }
39}
40
41pub fn run(
42 flow_path: &Path,
43 output_path: &Path,
44 signing: PackSigning,
45 meta_path: Option<&Path>,
46 component_dir: Option<&Path>,
47) -> Result<()> {
48 build_once(flow_path, output_path, signing, meta_path, component_dir)?;
49 if strict_mode_enabled() {
50 verify_determinism(flow_path, output_path, signing, meta_path, component_dir)?;
51 }
52 Ok(())
53}
54
55fn build_once(
56 flow_path: &Path,
57 output_path: &Path,
58 signing: PackSigning,
59 meta_path: Option<&Path>,
60 component_dir: Option<&Path>,
61) -> Result<()> {
62 let flow_source = fs::read_to_string(flow_path)
63 .with_context(|| format!("failed to read {}", flow_path.display()))?;
64 let flow_doc_json: JsonValue = serde_yaml_bw::from_str(&flow_source).with_context(|| {
65 format!(
66 "failed to parse {} for node resolution",
67 flow_path.display()
68 )
69 })?;
70 let bundle = load_and_validate_bundle(&flow_source, Some(flow_path))
71 .with_context(|| format!("flow validation failed for {}", flow_path.display()))?;
72
73 let mut resolver = ComponentResolver::new(component_dir.map(PathBuf::from));
74 let mut resolved_nodes = Vec::new();
75 let mut schema_errors = Vec::new();
76
77 for node in &bundle.nodes {
78 let resolved = resolver.resolve_node(node, &flow_doc_json)?;
79 schema_errors.extend(resolver.validate_node(&resolved)?);
80 resolved_nodes.push(resolved);
81 }
82
83 if !schema_errors.is_empty() {
84 report_schema_errors(&schema_errors)?;
85 }
86
87 write_resolved_configs(&resolved_nodes)?;
88
89 let meta = load_pack_meta(meta_path, &bundle)?;
90 let mut builder = PackBuilder::new(meta)
91 .with_flow(to_pack_flow_bundle(&bundle))
92 .with_signing(signing.into())
93 .with_provenance(build_provenance());
94
95 for artifact in collect_component_artifacts(&resolved_nodes) {
96 builder = builder.with_component(artifact);
97 }
98
99 if let Some(parent) = output_path.parent()
100 && !parent.as_os_str().is_empty()
101 {
102 fs::create_dir_all(parent)
103 .with_context(|| format!("failed to create {}", parent.display()))?;
104 }
105
106 let build_result = builder
107 .build(output_path)
108 .context("pack build failed (sign/build stage)")?;
109 println!(
110 "✓ Pack built at {} (manifest hash {})",
111 build_result.out_path.display(),
112 build_result.manifest_hash_blake3
113 );
114
115 Ok(())
116}
117
118fn strict_mode_enabled() -> bool {
119 matches!(
120 std::env::var("LOCAL_CHECK_STRICT")
121 .unwrap_or_default()
122 .as_str(),
123 "1" | "true" | "TRUE"
124 )
125}
126
127fn verify_determinism(
128 flow_path: &Path,
129 output_path: &Path,
130 signing: PackSigning,
131 meta_path: Option<&Path>,
132 component_dir: Option<&Path>,
133) -> Result<()> {
134 let temp_dir = tempfile::tempdir().context("failed to create tempdir for determinism check")?;
135 let temp_pack = temp_dir.path().join("deterministic.gtpack");
136 build_once(flow_path, &temp_pack, signing, meta_path, component_dir)
137 .context("determinism build failed")?;
138 let expected = fs::read(output_path).context("failed to read primary pack for determinism")?;
139 let actual = fs::read(&temp_pack).context("failed to read temp pack for determinism")?;
140 if expected != actual {
141 bail!("LOCAL_CHECK_STRICT detected non-deterministic pack output");
142 }
143 println!("LOCAL_CHECK_STRICT verified deterministic pack output");
144 Ok(())
145}
146
147fn to_pack_flow_bundle(bundle: &greentic_flow::flow_bundle::FlowBundle) -> PackFlowBundle {
148 PackFlowBundle {
149 id: bundle.id.clone(),
150 kind: bundle.kind.clone(),
151 entry: bundle.entry.clone(),
152 yaml: bundle.yaml.clone(),
153 json: bundle.json.clone(),
154 hash_blake3: bundle.hash_blake3.clone(),
155 nodes: bundle
156 .nodes
157 .iter()
158 .map(|node| PackNodeRef {
159 node_id: node.node_id.clone(),
160 component: PackComponentPin {
161 name: node.component.name.clone(),
162 version_req: node.component.version_req.clone(),
163 },
164 schema_id: node.schema_id.clone(),
165 })
166 .collect(),
167 }
168}
169
170fn write_resolved_configs(nodes: &[ResolvedNode]) -> Result<()> {
171 let root = Path::new(".greentic").join("resolved_config");
172 fs::create_dir_all(&root).context("failed to create .greentic/resolved_config")?;
173 for node in nodes {
174 let path = root.join(format!("{}.json", node.node_id));
175 let contents = serde_json::to_string_pretty(&json!({
176 "node_id": node.node_id,
177 "component": node.component.name,
178 "version": node.component.version.to_string(),
179 "config": node.config,
180 }))?;
181 fs::write(&path, contents)
182 .with_context(|| format!("failed to write {}", path.display()))?;
183 }
184 Ok(())
185}
186
187fn collect_component_artifacts(nodes: &[ResolvedNode]) -> Vec<ComponentArtifact> {
188 let mut map: HashMap<String, ComponentArtifact> = HashMap::new();
189 for node in nodes {
190 let component = &node.component;
191 let key = format!("{}@{}", component.name, component.version);
192 map.entry(key).or_insert_with(|| to_artifact(component));
193 }
194 map.into_values().collect()
195}
196
197fn to_artifact(component: &Arc<ResolvedComponent>) -> ComponentArtifact {
198 let hash = component
199 .wasm_hash
200 .strip_prefix("blake3:")
201 .unwrap_or(&component.wasm_hash)
202 .to_string();
203 ComponentArtifact {
204 name: component.name.clone(),
205 version: component.version.clone(),
206 wasm_path: component.wasm_path.clone(),
207 schema_json: component.schema_json.clone(),
208 manifest_json: component.manifest_json.clone(),
209 capabilities: component.capabilities_json.clone(),
210 world: Some(component.world.clone()),
211 hash_blake3: Some(hash),
212 }
213}
214
215fn report_schema_errors(errors: &[NodeSchemaError]) -> Result<()> {
216 let mut message = String::new();
217 for err in errors {
218 message.push_str(&format!(
219 "- node `{}` ({}) {}: {}\n",
220 err.node_id, err.component, err.pointer, err.message
221 ));
222 }
223 bail!("component schema validation failed:\n{message}");
224}
225
226fn load_pack_meta(
227 meta_path: Option<&Path>,
228 bundle: &greentic_flow::flow_bundle::FlowBundle,
229) -> Result<PackMeta> {
230 let config = if let Some(path) = meta_path {
231 let raw = fs::read_to_string(path)
232 .with_context(|| format!("failed to read {}", path.display()))?;
233 toml::from_str::<PackMetaToml>(&raw)
234 .with_context(|| format!("invalid pack metadata {}", path.display()))?
235 } else {
236 PackMetaToml::default()
237 };
238
239 let pack_id = config
240 .pack_id
241 .unwrap_or_else(|| format!("dev.local.{}", bundle.id));
242 let version = config
243 .version
244 .as_deref()
245 .unwrap_or("0.1.0")
246 .parse::<Version>()
247 .context("invalid pack version in metadata")?;
248 let pack_version = config.pack_version.unwrap_or(PACK_VERSION);
249 let name = config.name.unwrap_or_else(|| bundle.id.clone());
250 let description = config.description;
251 let authors = config.authors.unwrap_or_default();
252 let license = config.license;
253 let homepage = config.homepage;
254 let support = config.support;
255 let vendor = config.vendor;
256 let kind = config.kind;
257 let events = config.events;
258 let repo = config.repo;
259 let messaging = config.messaging;
260 let interfaces = config.interfaces.unwrap_or_default();
261 let imports = config
262 .imports
263 .unwrap_or_default()
264 .into_iter()
265 .map(|imp| ImportRef {
266 pack_id: imp.pack_id,
267 version_req: imp.version_req,
268 })
269 .collect();
270 let entry_flows = config
271 .entry_flows
272 .unwrap_or_else(|| vec![bundle.id.clone()]);
273 let created_at_utc = config.created_at_utc.unwrap_or_else(|| {
274 OffsetDateTime::now_utc()
275 .format(&Rfc3339)
276 .unwrap_or_default()
277 });
278 let annotations = config.annotations.map(toml_to_json_map).unwrap_or_default();
279
280 Ok(PackMeta {
281 pack_version,
282 pack_id,
283 version,
284 name,
285 description,
286 authors,
287 license,
288 homepage,
289 support,
290 vendor,
291 imports,
292 kind,
293 entry_flows,
294 created_at_utc,
295 events,
296 repo,
297 messaging,
298 interfaces,
299 annotations,
300 })
301}
302
303fn toml_to_json_map(table: toml::value::Table) -> serde_json::Map<String, JsonValue> {
304 table
305 .into_iter()
306 .map(|(key, value)| {
307 let json_value: JsonValue = value.try_into().unwrap_or(JsonValue::Null);
308 (key, json_value)
309 })
310 .collect()
311}
312
313fn build_provenance() -> Provenance {
314 Provenance {
315 builder: format!("greentic-dev {}", env!("CARGO_PKG_VERSION")),
316 git_commit: git_rev().ok(),
317 git_repo: git_remote().ok(),
318 toolchain: None,
319 built_at_utc: OffsetDateTime::now_utc()
320 .format(&Rfc3339)
321 .unwrap_or_else(|_| "unknown".into()),
322 host: std::env::var("HOSTNAME").ok(),
323 notes: Some("Built via greentic-dev pack build".into()),
324 }
325}
326
327fn git_rev() -> Result<String> {
328 let output = std::process::Command::new("git")
329 .args(["rev-parse", "HEAD"])
330 .output()?;
331 if !output.status.success() {
332 bail!("git rev-parse failed");
333 }
334 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
335}
336
337fn git_remote() -> Result<String> {
338 let output = std::process::Command::new("git")
339 .args(["config", "--get", "remote.origin.url"])
340 .output()?;
341 if !output.status.success() {
342 bail!("git remote lookup failed");
343 }
344 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
345}
346
347#[derive(Debug, Deserialize, Default)]
348struct PackMetaToml {
349 pack_version: Option<u32>,
350 pack_id: Option<String>,
351 version: Option<String>,
352 name: Option<String>,
353 kind: Option<PackKind>,
354 description: Option<String>,
355 authors: Option<Vec<String>>,
356 license: Option<String>,
357 homepage: Option<String>,
358 support: Option<String>,
359 vendor: Option<String>,
360 entry_flows: Option<Vec<String>>,
361 events: Option<EventsSection>,
362 repo: Option<RepoPackSection>,
363 messaging: Option<MessagingSection>,
364 interfaces: Option<Vec<InterfaceBinding>>,
365 imports: Option<Vec<ImportToml>>,
366 annotations: Option<toml::value::Table>,
367 created_at_utc: Option<String>,
368}
369
370#[derive(Debug, Deserialize)]
371struct ImportToml {
372 pack_id: String,
373 version_req: String,
374}