greentic_dev/
pack_build.rs

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}