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 expected = fs::read(output_path).context("failed to read primary pack for determinism")?;
166    let actual = fs::read(&temp_pack).context("failed to read temp pack for determinism")?;
167    if expected != actual {
168        bail!("LOCAL_CHECK_STRICT detected non-deterministic pack output");
169    }
170    println!("LOCAL_CHECK_STRICT verified deterministic pack output");
171    Ok(())
172}
173
174fn to_pack_flow_bundle(bundle: &greentic_flow::flow_bundle::FlowBundle) -> PackFlowBundle {
175    PackFlowBundle {
176        id: bundle.id.clone(),
177        kind: bundle.kind.clone(),
178        entry: bundle.entry.clone(),
179        yaml: bundle.yaml.clone(),
180        json: bundle.json.clone(),
181        hash_blake3: bundle.hash_blake3.clone(),
182        nodes: bundle
183            .nodes
184            .iter()
185            .map(|node| PackNodeRef {
186                node_id: node.node_id.clone(),
187                component: PackComponentPin {
188                    name: node.component.name.clone(),
189                    version_req: node.component.version_req.clone(),
190                },
191                schema_id: node.schema_id.clone(),
192            })
193            .collect(),
194    }
195}
196
197fn write_resolved_configs(nodes: &[ResolvedNode]) -> Result<()> {
198    let root = Path::new(".greentic").join("resolved_config");
199    fs::create_dir_all(&root).context("failed to create .greentic/resolved_config")?;
200    for node in nodes {
201        let path = root.join(format!("{}.json", node.node_id));
202        let contents = serde_json::to_string_pretty(&json!({
203            "node_id": node.node_id,
204            "component": node.component.name,
205            "version": node.component.version.to_string(),
206            "config": node.config,
207        }))?;
208        fs::write(&path, contents)
209            .with_context(|| format!("failed to write {}", path.display()))?;
210    }
211    Ok(())
212}
213
214fn collect_component_artifacts(nodes: &[ResolvedNode]) -> Vec<ComponentArtifact> {
215    let mut map: HashMap<String, ComponentArtifact> = HashMap::new();
216    for node in nodes {
217        let component = &node.component;
218        let key = format!("{}@{}", component.name, component.version);
219        map.entry(key).or_insert_with(|| to_artifact(component));
220    }
221    map.into_values().collect()
222}
223
224fn to_artifact(component: &Arc<ResolvedComponent>) -> ComponentArtifact {
225    let hash = component
226        .wasm_hash
227        .strip_prefix("blake3:")
228        .unwrap_or(&component.wasm_hash)
229        .to_string();
230    ComponentArtifact {
231        name: component.name.clone(),
232        version: component.version.clone(),
233        wasm_path: component.wasm_path.clone(),
234        schema_json: component.schema_json.clone(),
235        manifest_json: component.manifest_json.clone(),
236        capabilities: component.capabilities_json.clone(),
237        world: Some(component.world.clone()),
238        hash_blake3: Some(hash),
239    }
240}
241
242fn report_schema_errors(errors: &[NodeSchemaError]) -> Result<()> {
243    let mut message = String::new();
244    for err in errors {
245        message.push_str(&format!(
246            "- node `{}` ({}) {}: {}\n",
247            err.node_id, err.component, err.pointer, err.message
248        ));
249    }
250    bail!("component schema validation failed:\n{message}");
251}
252
253fn load_pack_meta(
254    meta_path: Option<&Path>,
255    bundle: &greentic_flow::flow_bundle::FlowBundle,
256) -> Result<PackMeta> {
257    let config = if let Some(path) = meta_path {
258        let raw = fs::read_to_string(path)
259            .with_context(|| format!("failed to read {}", path.display()))?;
260        toml::from_str::<PackMetaToml>(&raw)
261            .with_context(|| format!("invalid pack metadata {}", path.display()))?
262    } else {
263        PackMetaToml::default()
264    };
265
266    let pack_id = config
267        .pack_id
268        .unwrap_or_else(|| format!("dev.local.{}", bundle.id));
269    let version = config
270        .version
271        .as_deref()
272        .unwrap_or("0.1.0")
273        .parse::<Version>()
274        .context("invalid pack version in metadata")?;
275    let pack_version = config.pack_version.unwrap_or(PACK_VERSION);
276    let name = config.name.unwrap_or_else(|| bundle.id.clone());
277    let description = config.description;
278    let authors = config.authors.unwrap_or_default();
279    let license = config.license;
280    let homepage = config.homepage;
281    let support = config.support;
282    let vendor = config.vendor;
283    let kind = config.kind;
284    let events = config.events;
285    let repo = config.repo;
286    let messaging = config.messaging;
287    let interfaces = config.interfaces.unwrap_or_default();
288    let imports = config
289        .imports
290        .unwrap_or_default()
291        .into_iter()
292        .map(|imp| ImportRef {
293            pack_id: imp.pack_id,
294            version_req: imp.version_req,
295        })
296        .collect();
297    let entry_flows = config
298        .entry_flows
299        .unwrap_or_else(|| vec![bundle.id.clone()]);
300    let created_at_utc = config.created_at_utc.unwrap_or_else(|| {
301        OffsetDateTime::now_utc()
302            .format(&Rfc3339)
303            .unwrap_or_default()
304    });
305    let annotations = config.annotations.map(toml_to_json_map).unwrap_or_default();
306    let distribution = config.distribution;
307    let components = config.components.unwrap_or_default();
308
309    Ok(PackMeta {
310        pack_version,
311        pack_id,
312        version,
313        name,
314        description,
315        authors,
316        license,
317        homepage,
318        support,
319        vendor,
320        imports,
321        kind,
322        entry_flows,
323        created_at_utc,
324        events,
325        repo,
326        messaging,
327        interfaces,
328        annotations,
329        distribution,
330        components,
331    })
332}
333
334fn toml_to_json_map(table: toml::value::Table) -> serde_json::Map<String, JsonValue> {
335    table
336        .into_iter()
337        .map(|(key, value)| {
338            let json_value: JsonValue = value.try_into().unwrap_or(JsonValue::Null);
339            (key, json_value)
340        })
341        .collect()
342}
343
344fn build_provenance() -> Provenance {
345    Provenance {
346        builder: format!("greentic-dev {}", env!("CARGO_PKG_VERSION")),
347        git_commit: git_rev().ok(),
348        git_repo: git_remote().ok(),
349        toolchain: None,
350        built_at_utc: OffsetDateTime::now_utc()
351            .format(&Rfc3339)
352            .unwrap_or_else(|_| "unknown".into()),
353        host: std::env::var("HOSTNAME").ok(),
354        notes: Some("Built via greentic-dev pack build".into()),
355    }
356}
357
358fn git_rev() -> Result<String> {
359    let output = std::process::Command::new("git")
360        .args(["rev-parse", "HEAD"])
361        .output()?;
362    if !output.status.success() {
363        bail!("git rev-parse failed");
364    }
365    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
366}
367
368fn git_remote() -> Result<String> {
369    let output = std::process::Command::new("git")
370        .args(["config", "--get", "remote.origin.url"])
371        .output()?;
372    if !output.status.success() {
373        bail!("git remote lookup failed");
374    }
375    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
376}
377
378#[derive(Debug, Deserialize, Default)]
379struct PackMetaToml {
380    pack_version: Option<u32>,
381    pack_id: Option<String>,
382    version: Option<String>,
383    name: Option<String>,
384    kind: Option<PackKind>,
385    description: Option<String>,
386    authors: Option<Vec<String>>,
387    license: Option<String>,
388    homepage: Option<String>,
389    support: Option<String>,
390    vendor: Option<String>,
391    entry_flows: Option<Vec<String>>,
392    events: Option<EventsSection>,
393    repo: Option<RepoPackSection>,
394    messaging: Option<MessagingSection>,
395    interfaces: Option<Vec<InterfaceBinding>>,
396    imports: Option<Vec<ImportToml>>,
397    annotations: Option<toml::value::Table>,
398    created_at_utc: Option<String>,
399    distribution: Option<DistributionSection>,
400    components: Option<Vec<ComponentDescriptor>>,
401}
402
403#[derive(Debug, Deserialize)]
404struct ImportToml {
405    pack_id: String,
406    version_req: String,
407}