Skip to main content

clawft_kernel/node_facts/
runtimes.rs

1//! Runtime probes: native, Docker-compatible engines (OrbStack, Docker
2//! Desktop, plain engine), Podman, Apple `container`, and the inference
3//! servers `llama-server`, `mlx_lm` and `ollama`.
4//!
5//! Emulated arches are only advertised when a probe sees an emulator:
6//! the host's binfmt_misc on Linux, Rosetta on macOS, and for a VM-backed
7//! engine the binfmt handlers inside the engine VM (listed from a local
8//! image, never pulled) unioned with the BuildKit platform list.
9
10use clawft_types::placement::{CapabilityState, Provenance};
11
12use super::host::ProbeHost;
13use super::linux::{emulated_from_listing, host_binfmt_emulated};
14use super::probe::{Collected, ProbeConfig, cap, normalize_arch, str_list};
15
16const ROSETTA: &str = "/Library/Apple/usr/share/rosetta/rosetta";
17
18/// Shell snippet run inside the engine VM to list binfmt handlers.
19pub const BINFMT_LIST: &str = "mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc 2>/dev/null; \
20cd /proc/sys/fs/binfmt_misc && for f in *; do [ \"$f\" = register ] || [ \"$f\" = status ] || \
21echo \"$f $(head -n1 \"$f\")\"; done";
22
23fn host_emulated(host: &dyn ProbeHost, arch: &str) -> Vec<String> {
24    match host.os().as_str() {
25        "linux" => host_binfmt_emulated(host, arch),
26        "macos" if host.exists(ROSETTA) && arch != "x86_64" => vec!["x86_64".into()],
27        _ => Vec::new(),
28    }
29}
30
31/// Run the runtime probes.
32pub fn probe(host: &dyn ProbeHost, arch: &str, cfg: &ProbeConfig, c: &mut Collected) {
33    let host_emu = host_emulated(host, arch);
34    if let Some(n) = cap("runtime.native", Provenance::Probed) {
35        c.push(
36            n.with_attr("arches_native", str_list(&[arch]))
37                .with_attr("arches_emulated", str_list(&host_emu)),
38        );
39    }
40    docker(host, arch, &host_emu, cfg, c);
41    podman(host, arch, &host_emu, c);
42    if host.os() == "macos" {
43        apple_container(host, arch, &host_emu, c);
44    }
45    inference(host, arch, c);
46}
47
48/// Docker engine variant from `docker info` `OperatingSystem`.
49pub fn docker_variant(operating_system: &str) -> &'static str {
50    if operating_system.contains("OrbStack") {
51        "orbstack"
52    } else if operating_system.contains("Docker Desktop") {
53        "desktop"
54    } else {
55        "engine"
56    }
57}
58
59/// Arches from a `docker buildx inspect` `Platforms:` line.
60pub fn buildx_arches(out: &str) -> Vec<String> {
61    let Some(line) = out
62        .lines()
63        .find(|l| l.trim_start().starts_with("Platforms:"))
64    else {
65        return Vec::new();
66    };
67    let mut v: Vec<String> = line
68        .split_once(':')
69        .map(|(_, r)| r)
70        .unwrap_or_default()
71        .split(',')
72        .filter_map(|p| p.trim().strip_prefix("linux/"))
73        .map(|p| match p {
74            "arm/v7" | "arm/v6" => normalize_arch(p),
75            other => normalize_arch(other.split('/').next().unwrap_or(other)),
76        })
77        .filter(|a| !a.is_empty())
78        .collect();
79    v.sort();
80    v.dedup();
81    v
82}
83
84fn docker(
85    host: &dyn ProbeHost,
86    arch: &str,
87    host_emu: &[String],
88    cfg: &ProbeConfig,
89    c: &mut Collected,
90) {
91    if host.which("docker").is_none() {
92        return;
93    }
94    let info = host
95        .run("docker", &["info", "--format", "{{json .}}"])
96        .and_then(|s| serde_json::from_str::<serde_json::Value>(s.trim()).ok());
97    let Some(info) = info.filter(|v| v["ServerVersion"].is_string()) else {
98        if let Some(d) = cap("runtime.container.docker", Provenance::Probed) {
99            c.push(d.with_state(CapabilityState::Degraded));
100        }
101        c.note(
102            "docker",
103            "docker CLI present but the engine did not answer `docker info`; degraded",
104        );
105        return;
106    };
107    let os = info["OperatingSystem"].as_str().unwrap_or_default();
108    let variant = docker_variant(os);
109    let native = info["Architecture"]
110        .as_str()
111        .map(normalize_arch)
112        .unwrap_or_else(|| arch.into());
113    let local_linux_engine = host.os() == "linux" && variant == "engine";
114    let (mut emulated, source) = if local_linux_engine {
115        (
116            host_emu.to_vec(),
117            "host binfmt_misc (shared kernel)".to_string(),
118        )
119    } else {
120        vm_emulation(host, &native, cfg)
121    };
122    emulated.retain(|a| *a != native);
123    let Some(mut d) = cap("runtime.container.docker", Provenance::Probed) else {
124        return;
125    };
126    d = d
127        .with_attr("variant", variant)
128        .with_attr("arches_native", str_list(&[native.as_str()]))
129        .with_attr("arches_emulated", str_list(&emulated));
130    if let Some(v) = info["ServerVersion"].as_str() {
131        d = d.with_attr("version", v);
132    }
133    c.push(d);
134    // The CLI answers for whichever engine the daemon's own HOME and docker
135    // context select, which may not be the one an interactive shell uses.
136    let context = host
137        .run("docker", &["context", "show"])
138        .map(|s| public_context(s.trim()))
139        .unwrap_or_else(|| "unknown".into());
140    c.note(
141        "docker",
142        format!(
143            "docker info (variant {variant}); emulation from {source}; \
144             probed as the daemon's user with docker context {context}"
145        ),
146    );
147}
148
149/// Docker context names that are the same on every install. A context a user
150/// named themselves (a host, a project, a person) is reported as `custom`.
151const KNOWN_CONTEXTS: &[&str] = &["default", "desktop-linux", "orbstack", "colima", "rancher-desktop"];
152
153fn public_context(name: &str) -> String {
154    if KNOWN_CONTEXTS.contains(&name) {
155        name.to_string()
156    } else if name.is_empty() {
157        "unknown".into()
158    } else {
159        "custom".into()
160    }
161}
162
163fn vm_emulation(host: &dyn ProbeHost, native: &str, cfg: &ProbeConfig) -> (Vec<String>, String) {
164    let mut arches = host
165        .run("docker", &["buildx", "inspect"])
166        .map(|o| buildx_arches(&o))
167        .unwrap_or_default();
168    let mut sources = vec!["buildx platforms"];
169    let image = cfg.docker_probe_image.as_deref().filter(|img| {
170        super::probe::valid_image_ref(img)
171            && host
172                .run("docker", &["image", "inspect", "--format", "{{.Id}}", img])
173                .is_some()
174    });
175    match image {
176        Some(img) => {
177            let cached = cfg.emulation_cache.as_ref().and_then(|c| c.get(img));
178            let listing = cached.or_else(|| {
179                let l = host.run(
180                    "docker",
181                    &[
182                        "run",
183                        "--rm",
184                        "--privileged",
185                        "--pull=never",
186                        "--network=none",
187                        "--entrypoint",
188                        "/bin/sh",
189                        img,
190                        "-c",
191                        BINFMT_LIST,
192                    ],
193                );
194                if let (Some(l), Some(c)) = (&l, &cfg.emulation_cache) {
195                    c.put(img, l);
196                }
197                l
198            });
199            if let Some(l) = listing {
200                arches.extend(emulated_from_listing(&l, native));
201                sources.push("engine VM binfmt_misc");
202            }
203        }
204        None => sources.push("(engine VM binfmt not listed: probe image not present locally)"),
205    }
206    arches.retain(|a| a != native);
207    arches.sort();
208    arches.dedup();
209    (arches, sources.join(" + "))
210}
211
212fn podman(host: &dyn ProbeHost, arch: &str, host_emu: &[String], c: &mut Collected) {
213    if host.which("podman").is_none() {
214        return;
215    }
216    let info = host
217        .run("podman", &["info", "--format", "json"])
218        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
219    let Some(mut p) = cap("runtime.container.podman", Provenance::Probed) else {
220        return;
221    };
222    let Some(info) = info else {
223        c.push(p.with_state(CapabilityState::Degraded));
224        c.note(
225            "podman",
226            "podman present but `podman info` failed; degraded",
227        );
228        return;
229    };
230    let native = info["host"]["arch"]
231        .as_str()
232        .map(normalize_arch)
233        .unwrap_or_else(|| arch.into());
234    let emulated: Vec<String> = if host.os() == "linux" {
235        host_emu.to_vec()
236    } else {
237        Vec::new()
238    };
239    p = p
240        .with_attr("arches_native", str_list(&[native.as_str()]))
241        .with_attr("arches_emulated", str_list(&emulated));
242    if let Some(v) = info["version"]["Version"].as_str() {
243        p = p.with_attr("version", v);
244    }
245    c.push(p);
246}
247
248fn apple_container(host: &dyn ProbeHost, arch: &str, host_emu: &[String], c: &mut Collected) {
249    let Some(out) = host.run("container", &["--version"]) else {
250        return;
251    };
252    let Some(mut a) = cap("runtime.container.apple", Provenance::Probed) else {
253        return;
254    };
255    if let Some(v) = out
256        .split("version")
257        .nth(1)
258        .and_then(|r| r.split_whitespace().next())
259    {
260        a = a.with_attr("version", v);
261    }
262    a = a
263        .with_attr("arches_native", str_list(&[arch]))
264        .with_attr("arches_emulated", str_list(host_emu));
265    if host.run("container", &["system", "status"]).is_none() {
266        a = a.with_state(CapabilityState::Degraded);
267        c.note(
268            "container",
269            "Apple container CLI present but its system service is not running; degraded",
270        );
271    }
272    c.push(a);
273}
274
275fn inference(host: &dyn ProbeHost, arch: &str, c: &mut Collected) {
276    if host.which("llama-server").is_some()
277        && let Some(mut l) = cap("runtime.infer.llamacpp", Provenance::Probed)
278    {
279        let ver = host.run_all("llama-server", &["--version"]).and_then(|o| {
280            o.lines()
281                .find_map(|l| l.trim().strip_prefix("version:"))
282                .and_then(|v| v.split_whitespace().next().map(str::to_string))
283        });
284        if let Some(v) = ver {
285            l = l.with_attr("version", v.as_str());
286        }
287        c.push(
288            l.with_attr("formats", str_list(&["gguf"]))
289                .with_attr("arches_native", str_list(&[arch])),
290        );
291    }
292    if (host.which("mlx_lm.server").is_some() || host.which("mlx_lm.generate").is_some())
293        && let Some(m) = cap("runtime.infer.mlx-lm", Provenance::Probed)
294    {
295        c.push(
296            m.with_attr("formats", str_list(&["mlx"]))
297                .with_attr("arches_native", str_list(&[arch])),
298        );
299        c.note(
300            "runtime.infer.mlx-lm",
301            "mlx_lm entry point on PATH; version not probed (python import is slow)",
302        );
303    }
304    if host.which("ollama").is_some()
305        && let Some(mut o) = cap("runtime.infer.ollama", Provenance::Probed)
306    {
307        if let Some(v) = host
308            .run("ollama", &["--version"])
309            .and_then(|s| s.split("version is").nth(1).map(|v| v.trim().to_string()))
310            .filter(|v| !v.is_empty())
311        {
312            o = o.with_attr("version", v.as_str());
313        }
314        c.push(
315            o.with_attr("formats", str_list(&["gguf"]))
316                .with_attr("arches_native", str_list(&[arch])),
317        );
318    }
319}