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