clawft_kernel/node_facts/
linux.rs1use clawft_types::placement::Provenance;
9
10use super::host::ProbeHost;
11use super::probe::{Collected, bytes_attr, cap, normalize_arch, note_external_withheld, parse_df, public_mount};
12
13const BINFMT: &str = "/proc/sys/fs/binfmt_misc";
14const EXTERNAL_ROOTS: &[&str] = &["/media/", "/mnt/", "/run/media/"];
16
17pub fn binfmt_arch(name: &str) -> Option<String> {
20 if name.starts_with("rosetta") {
21 return Some("x86_64".into());
22 }
23 let arch = name.strip_prefix("qemu-")?;
24 Some(match arch {
25 "arm" => "armv7".into(),
26 other => normalize_arch(other),
27 })
28 .filter(|a: &String| !a.is_empty())
29}
30
31pub fn emulated_from_listing(lines: &str, native: &str) -> Vec<String> {
34 let mut out: Vec<String> = lines
35 .lines()
36 .filter_map(|l| {
37 let mut parts = l.split_whitespace();
38 let name = parts.next()?;
39 match parts.next() {
40 Some("enabled") | None => binfmt_arch(name),
41 _ => None,
42 }
43 })
44 .filter(|a| a != native)
45 .collect();
46 out.sort();
47 out.dedup();
48 out
49}
50
51pub fn host_binfmt_emulated(host: &dyn ProbeHost, native: &str) -> Vec<String> {
53 let listing: String = host
54 .list_dir(BINFMT)
55 .unwrap_or_default()
56 .into_iter()
57 .filter(|n| n != "register" && n != "status")
58 .map(|n| {
59 let state = host
60 .read_file(&format!("{BINFMT}/{n}"))
61 .and_then(|s| s.lines().next().map(str::to_string))
62 .unwrap_or_default();
63 format!("{n} {state}\n")
64 })
65 .collect();
66 emulated_from_listing(&listing, native)
67}
68
69fn kv<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
70 text.lines()
71 .filter_map(|l| l.split_once(sep))
72 .find(|(k, _)| k.trim() == key)
73 .map(|(_, v)| v.trim().trim_matches('"'))
74}
75
76pub fn live_free(host: &dyn ProbeHost) -> Option<u64> {
78 host.read_file("/proc/meminfo")
79 .as_deref()
80 .and_then(|t| meminfo_bytes(t, "MemAvailable"))
81}
82
83fn meminfo_bytes(text: &str, key: &str) -> Option<u64> {
84 kv(text, key, ':')?
85 .split_whitespace()
86 .next()?
87 .parse::<u64>()
88 .ok()
89 .map(|kb| kb.saturating_mul(1024))
90}
91
92pub fn probe(host: &dyn ProbeHost, arch: &str, c: &mut Collected) {
94 let cpuinfo = host.read_file("/proc/cpuinfo").unwrap_or_default();
95 let board = host
96 .read_file("/proc/device-tree/model")
97 .map(|s| s.trim_end_matches('\0').trim().to_string())
98 .filter(|s| !s.is_empty());
99
100 if let Some(mut cp) = cap(&format!("cpu.arch.{arch}"), Provenance::Probed) {
101 let cores = cpuinfo
102 .lines()
103 .filter(|l| l.starts_with("processor"))
104 .count();
105 if cores > 0 {
106 cp = cp.with_attr("cores", cores as i64);
107 }
108 let model = board
109 .clone()
110 .or_else(|| kv(&cpuinfo, "model name", ':').map(str::to_string));
111 if let Some(m) = model {
112 cp = cp.with_attr("model", m.as_str());
113 }
114 c.push(cp);
115 }
116
117 if let Some(mut os) = cap("os.linux", Provenance::Probed) {
118 if let Some(d) = host
119 .read_file("/etc/os-release")
120 .as_deref()
121 .and_then(|t| kv(t, "PRETTY_NAME", '=').map(str::to_string))
122 {
123 os = os.with_attr("distro", d.as_str());
124 }
125 if let Some(k) = host.run("uname", &["-r"]) {
126 os = os.with_attr("kernel", k.trim());
127 }
128 c.push(os);
129 }
130
131 if let Some(mi) = host.read_file("/proc/meminfo")
132 && let (Some(total), Some(m)) = (
133 meminfo_bytes(&mi, "MemTotal"),
134 cap("mem.system", Provenance::Probed),
135 )
136 {
137 let mut m = m.with_attr("total", bytes_attr(total));
138 if let Some(avail) = meminfo_bytes(&mi, "MemAvailable") {
139 m = m.with_attr("free", bytes_attr(avail));
140 }
141 c.push(m);
142 }
143
144 storage(host, c);
145
146 if let Some(b) = &board {
147 let class = if b.contains("Raspberry Pi 5") {
148 "pi5"
149 } else {
150 "other"
151 };
152 if let Some(nc) = cap(&format!("node.class.{class}"), Provenance::Probed) {
153 c.push(nc.with_attr("board", b.as_str()));
154 }
155 }
156}
157
158fn storage(host: &dyn ProbeHost, c: &mut Collected) {
159 if let Some((free, mount)) = host.run("df", &["-kP", "/"]).as_deref().and_then(parse_df)
160 && let Some(s) = cap("store.tier.internal", Provenance::Probed)
161 {
162 let mut s = s.with_attr("free", bytes_attr(free));
163 if let Some(m) = public_mount(&mount) {
164 s = s.with_attr("mount", m);
165 }
166 c.push(s);
167 }
168 let mounts = host.read_file("/proc/mounts").unwrap_or_default();
169 let mut external = 0;
170 for line in mounts.lines() {
171 let Some(mp) = line.split_whitespace().nth(1) else {
172 continue;
173 };
174 let mp = mp.replace("\\040", " ");
175 if !EXTERNAL_ROOTS.iter().any(|r| mp.starts_with(r)) {
176 continue;
177 }
178 let free = host.run("df", &["-kP", &mp]).as_deref().and_then(parse_df);
179 if let Some(mut s) = cap("store.tier.external", Provenance::Probed) {
181 s = s.with_attr("mounted", true);
182 if let Some((f, _)) = free {
183 s = s.with_attr("free", bytes_attr(f));
184 }
185 c.push(s);
186 external += 1;
187 }
188 }
189 note_external_withheld(c, external);
190}