Skip to main content

hatchery_node/
machine_env.rs

1//! Collect host machine resources + installed provider CLI versions for topology inventory.
2
3use hatchery_core::{MachineEnvironment, ProviderCliReport};
4use std::fs;
5use std::process::Command;
6
7const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi", "kimi-cli", "bash"];
8
9/// Snapshot the host environment. Best-effort: missing fields become 0 / empty.
10pub fn collect() -> MachineEnvironment {
11    let (os, kernel, arch) = uname();
12    let hostname = fs::read_to_string("/etc/hostname")
13        .or_else(|_| fs::read_to_string("/proc/sys/kernel/hostname"))
14        .map(|s| s.trim().to_owned())
15        .unwrap_or_else(|_| "unknown".into());
16    let uptime_secs = fs::read_to_string("/proc/uptime")
17        .ok()
18        .and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
19        .map(|s| s as u64)
20        .unwrap_or(0);
21    let cpu_cores = std::thread::available_parallelism()
22        .map(|n| n.get() as u32)
23        .unwrap_or(1);
24    let cpu_model = cpu_model();
25    let (ram_total_bytes, ram_available_bytes) = meminfo();
26    let (disk_total_bytes, disk_available_bytes) = disk_root();
27    let provider_clis = PROVIDERS
28        .iter()
29        .filter_map(|id| probe_cli(id))
30        .collect();
31    MachineEnvironment {
32        os,
33        kernel,
34        arch,
35        hostname,
36        uptime_secs,
37        cpu_cores,
38        cpu_model,
39        ram_total_bytes,
40        ram_available_bytes,
41        disk_total_bytes,
42        disk_available_bytes,
43        provider_clis,
44    }
45}
46
47fn uname() -> (String, String, String) {
48    let out = Command::new("uname").args(["-srm"]).output().ok();
49    let text = out
50        .and_then(|o| String::from_utf8(o.stdout).ok())
51        .unwrap_or_default();
52    let mut parts = text.split_whitespace();
53    let os = parts.next().unwrap_or(std::env::consts::OS).to_owned();
54    let kernel = parts.next().unwrap_or("unknown").to_owned();
55    let arch = parts
56        .next()
57        .unwrap_or(std::env::consts::ARCH)
58        .to_owned();
59    (os, kernel, arch)
60}
61
62fn cpu_model() -> Option<String> {
63    let text = fs::read_to_string("/proc/cpuinfo").ok()?;
64    for line in text.lines() {
65        if let Some(rest) = line.strip_prefix("model name") {
66            return Some(rest.trim_start_matches(|c: char| c == ':' || c.is_whitespace()).to_owned());
67        }
68    }
69    None
70}
71
72fn meminfo() -> (u64, u64) {
73    let text = match fs::read_to_string("/proc/meminfo") {
74        Ok(t) => t,
75        Err(_) => return (0, 0),
76    };
77    let mut total_kb = 0u64;
78    let mut avail_kb = 0u64;
79    for line in text.lines() {
80        if let Some(v) = line.strip_prefix("MemTotal:") {
81            total_kb = parse_kb(v);
82        } else if let Some(v) = line.strip_prefix("MemAvailable:") {
83            avail_kb = parse_kb(v);
84        }
85    }
86    (total_kb.saturating_mul(1024), avail_kb.saturating_mul(1024))
87}
88
89fn parse_kb(v: &str) -> u64 {
90    v.split_whitespace()
91        .next()
92        .and_then(|n| n.parse().ok())
93        .unwrap_or(0)
94}
95
96fn disk_root() -> (u64, u64) {
97    // Prefer `statvfs` via df -B1 /
98    let out = Command::new("df").args(["-B1", "/"]).output().ok();
99    let text = out.and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
100    for line in text.lines().skip(1) {
101        let cols: Vec<_> = line.split_whitespace().collect();
102        if cols.len() >= 4 {
103            let total = cols[1].parse().unwrap_or(0);
104            let avail = cols[3].parse().unwrap_or(0);
105            return (total, avail);
106        }
107    }
108    (0, 0)
109}
110
111fn probe_cli(id: &str) -> Option<ProviderCliReport> {
112    let path = Command::new("sh")
113        .args(["-c", &format!("command -v {id}")])
114        .output()
115        .ok()
116        .and_then(|o| {
117            if o.status.success() {
118                String::from_utf8(o.stdout).ok().map(|s| s.trim().to_owned())
119            } else {
120                None
121            }
122        })
123        .filter(|s| !s.is_empty())?;
124    let version = Command::new(&path)
125        .arg("--version")
126        .output()
127        .ok()
128        .and_then(|o| {
129            let mut text = String::from_utf8_lossy(&o.stdout).to_string();
130            if text.trim().is_empty() {
131                text = String::from_utf8_lossy(&o.stderr).to_string();
132            }
133            let line = text.lines().next()?.trim().to_owned();
134            if line.is_empty() { None } else { Some(line) }
135        });
136    Some(ProviderCliReport {
137        id: id.to_owned(),
138        version,
139        path: Some(path),
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn collect_returns_nonzero_cores_on_linux() {
149        let env = collect();
150        assert!(env.cpu_cores >= 1);
151        assert!(!env.os.is_empty());
152        assert!(!env.arch.is_empty());
153    }
154}