Skip to main content

consortium_nix/
build.rs

1//! Nix build orchestration — build closures locally or with distributed builders.
2
3use std::collections::HashMap;
4use std::io::Write as IoWrite;
5use std::process::Command;
6
7use crate::config::DeploymentPlan;
8use crate::error::{NixError, Result};
9use crate::health::HealthStatus;
10
11/// Build results keyed by hostname.
12pub struct BuildResults {
13    /// Map of hostname -> built store path.
14    pub paths: HashMap<String, String>,
15    /// Map of hostname -> build error.
16    pub errors: HashMap<String, NixError>,
17}
18
19/// Build all closures in a deployment plan.
20///
21/// If healthy builders are provided, generates a temporary machines file
22/// and uses Nix's native distributed build mechanism.
23pub fn build_closures(
24    plan: &DeploymentPlan,
25    flake_uri: &str,
26    healthy_builders: Option<&[HealthStatus]>,
27) -> Result<BuildResults> {
28    let mut results = BuildResults {
29        paths: HashMap::new(),
30        errors: HashMap::new(),
31    };
32
33    // Generate temporary machines file if we have healthy builders
34    let machines_file = healthy_builders
35        .map(|builders| generate_machines_file_from_healthy(builders))
36        .transpose()?;
37
38    // TODO: parallelize builds with consortium's Task/Worker infrastructure
39    for target in &plan.targets {
40        if !target.needs_build {
41            results
42                .paths
43                .insert(target.node.name.clone(), target.toplevel_path.clone());
44            continue;
45        }
46
47        match build_host(flake_uri, &target.node.name, machines_file.as_deref()) {
48            Ok(path) => {
49                results.paths.insert(target.node.name.clone(), path);
50            }
51            Err(e) => {
52                results.errors.insert(target.node.name.clone(), e);
53            }
54        }
55    }
56
57    Ok(results)
58}
59
60/// Build any flake attribute and return its store path.
61///
62/// This is the generic build primitive — consortium-ansible uses it for
63/// `ansibleEnvs.{name}`, consortium-slurm for `slurmEnvs.{name}`, etc.
64pub fn build_flake_attr(flake_attr: &str, machines_file: Option<&str>) -> Result<String> {
65    let mut cmd = Command::new("nix");
66    cmd.args(["build", flake_attr, "--no-link", "--print-out-paths"]);
67
68    if let Some(path) = machines_file {
69        cmd.arg("--builders").arg(format!("@{}", path));
70    }
71
72    let output = cmd.output().map_err(|e| NixError::BuildFailed {
73        host: flake_attr.to_string(),
74        message: format!("failed to run nix build: {}", e),
75    })?;
76
77    if !output.status.success() {
78        let stderr = String::from_utf8_lossy(&output.stderr);
79        return Err(NixError::BuildFailed {
80            host: flake_attr.to_string(),
81            message: stderr.to_string(),
82        });
83    }
84
85    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
86    if path.is_empty() {
87        return Err(NixError::BuildFailed {
88            host: flake_attr.to_string(),
89            message: "nix build returned empty path".to_string(),
90        });
91    }
92
93    Ok(path)
94}
95
96/// Build the system closure for a single host.
97pub fn build_host(flake_uri: &str, hostname: &str, machines_file: Option<&str>) -> Result<String> {
98    let attr = format!(
99        "{}#nixosConfigurations.{}.config.system.build.toplevel",
100        flake_uri, hostname
101    );
102    build_flake_attr(&attr, machines_file)
103}
104
105/// Generate a temporary machines file from healthy builders.
106/// Public so the deploy pipeline can call it before building the DAG.
107pub fn generate_machines_file_from_healthy(builders: &[HealthStatus]) -> Result<String> {
108    let content: String = builders
109        .iter()
110        .filter(|b| b.healthy)
111        .map(|b| {
112            let key = b.builder.ssh_key.as_deref().unwrap_or("-");
113            let features = b.builder.features.join(",");
114            let systems = b.builder.systems.join(",");
115            format!(
116                "{}://{}@{} {} {} {} {} {}",
117                b.builder.protocol,
118                b.builder.user,
119                b.builder.host,
120                systems,
121                key,
122                b.builder.max_jobs,
123                b.builder.speed_factor,
124                features
125            )
126        })
127        .collect::<Vec<_>>()
128        .join("\n");
129
130    let dir = std::env::temp_dir().join("consortium-nix");
131    std::fs::create_dir_all(&dir).map_err(|e| NixError::MachinesFile {
132        path: dir.clone(),
133        source: e,
134    })?;
135
136    let path = dir.join("machines");
137    let mut file = std::fs::File::create(&path).map_err(|e| NixError::MachinesFile {
138        path: path.clone(),
139        source: e,
140    })?;
141    file.write_all(content.as_bytes())
142        .map_err(|e| NixError::MachinesFile {
143            path: path.clone(),
144            source: e,
145        })?;
146
147    Ok(path.to_string_lossy().to_string())
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::config::Builder;
154
155    #[test]
156    fn test_generate_machines_file() {
157        let builders = vec![HealthStatus {
158            builder: Builder {
159                host: "192.168.1.121".to_string(),
160                user: "root".to_string(),
161                max_jobs: 16,
162                speed_factor: 2,
163                systems: vec!["x86_64-linux".to_string()],
164                features: vec!["big-parallel".to_string(), "kvm".to_string()],
165                ssh_key: None,
166                protocol: "ssh-ng".to_string(),
167            },
168            healthy: true,
169            latency_ms: Some(5),
170            error: None,
171        }];
172
173        let path = generate_machines_file_from_healthy(&builders).unwrap();
174        let content = std::fs::read_to_string(&path).unwrap();
175        assert!(content.contains("ssh-ng://root@192.168.1.121"));
176        assert!(content.contains("x86_64-linux"));
177        assert!(content.contains("big-parallel,kvm"));
178        std::fs::remove_file(path).ok();
179    }
180}