greentic_dev/
pack_build.rs

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