Skip to main content

flodl_cli/
probe.rs

1//! `fdl probe` — check host readiness for training.
2//!
3//! Single-host (default): probes the local box for GPU + libtorch +
4//! shared-data path + NCCL. Cluster context (env overlay): each
5//! configured host is probed via SSH and the per-host status is
6//! aggregated into one report. See [`run`].
7//!
8//! Design notes
9//! ============
10//! flodl assumes shared storage is available to every node at the
11//! same logical path (NAS / SMB / virtiofs / S3-FUSE / SSHFS). The
12//! probe is the gate that confirms each host can see it BEFORE
13//! training fans out, instead of discovering it mid-AllReduce when a
14//! checkpoint write hangs on a stale mount. The convention default
15//! ([`crate::config::DEFAULT_DATA_PATH`]) applies when a host does
16//! not declare `data_path:` in `fdl.cluster.yml`.
17//!
18//! Probe is intentionally thin — it reuses
19//! [`crate::libtorch::detect`] and [`crate::util::system`] for the
20//! existing detection logic and adds shared-mount + NCCL discovery
21//! on top. The format is `diagnose`-style (text by default, `--json`
22//! emits machine-readable output for `fdl deploy` / CI to consume).
23
24use std::fmt::Write;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::cluster::resolve_local_hostname;
29use crate::config::{self, ClusterWorker, DEFAULT_DATA_PATH};
30use crate::context::Context;
31use crate::libtorch::detect::{self, LibtorchInfo};
32use crate::util::requirements;
33use crate::util::system::{self, GpuInfo};
34use flodl_hw::{GpuArch, GpuVendor};
35
36// ---------------------------------------------------------------------------
37// Public entry
38// ---------------------------------------------------------------------------
39
40/// Run the probe.
41///
42/// **Single-host** (no active env overlay): probes the local box and
43/// emits one report. `--data-path` overrides config; `--skip-mount`
44/// short-circuits the shared-data check.
45///
46/// **Cluster** (`fdl @cluster probe` / `FDL_ENV=cluster`): loads
47/// `fdl.<env>.yml`'s `cluster.workers:` list. For each host: if it's
48/// the local host, probes in-process; otherwise SSHes to it and runs
49/// `<worker.path>/target/release/fdl probe --json` remotely. Per-host
50/// JSON is parsed back into [`ProbeReport`] and aggregated.
51///
52/// Exit code: `0` when every probed host is green; `1` when any
53/// host raised issues.
54pub fn run(
55    json: bool,
56    skip_mount: bool,
57    data_path_override: Option<PathBuf>,
58    libtorch_path_override: Option<PathBuf>,
59    via_docker: Option<String>,
60) -> i32 {
61    let ctx = Context::resolve();
62    // Cluster fan-out only applies when no explicit libtorch override
63    // is passed — overrides are how the *remote* probe is invoked, so
64    // we must NOT recurse back into cluster mode on the remote side.
65    if libtorch_path_override.is_none()
66        && let Ok(env_name) = std::env::var("FDL_ENV")
67        && let Some(cluster) = load_cluster_for_env(&ctx, &env_name)
68    {
69        return run_cluster(&cluster, json, skip_mount);
70    }
71    // Single-host (local OR remote-being-probed). When `--data-path` is
72    // passed explicitly, treat a missing path as an error; when absent
73    // (falling back to DEFAULT_DATA_PATH), treat it as a warning.
74    let data_path_explicit = data_path_override.is_some();
75    let report = probe_local(
76        &ctx,
77        skip_mount,
78        data_path_override,
79        libtorch_path_override,
80        via_docker,
81        data_path_explicit,
82    );
83    if json {
84        print_json(&report);
85    } else {
86        print_report(&report);
87    }
88    if report.green() { 0 } else { 1 }
89}
90
91fn load_cluster_for_env(ctx: &Context, env_name: &str) -> Option<config::ClusterConfig> {
92    let config_path = config::find_config(&ctx.root)?;
93    let project = config::load_project_with_env(&config_path, Some(env_name)).ok()?;
94    project.cluster
95}
96
97// ---------------------------------------------------------------------------
98// Cluster fan-out
99// ---------------------------------------------------------------------------
100
101fn run_cluster(cluster: &config::ClusterConfig, json: bool, skip_mount: bool) -> i32 {
102    let local = resolve_local_hostname();
103    let mut reports: Vec<ProbeReport> = Vec::with_capacity(cluster.workers.len());
104    for worker in &cluster.workers {
105        let r = if worker.host == local {
106            // Local rank: probe in-process, honor the host's data_path,
107            // arch (libtorch variant), and docker service (if set in cluster.yml).
108            // Matches the remote-probe path so the local rank's report
109            // shape is identical to the SSH-probed remotes. Only pass an
110            // explicit data_path_override when the host declared one;
111            // omitting it preserves the "default = warning, not error"
112            // semantics in [`check_data_path`].
113            let ctx = Context::resolve();
114            let data_path_explicit = worker.data_path.is_some();
115            probe_local(
116                &ctx,
117                skip_mount,
118                worker.data_path.as_ref().map(PathBuf::from),
119                // Convention: libtorch lives at `<worker.path>/libtorch/<worker.arch>`
120                // when the host declares an arch; else probe walks
121                // `<worker.path>/libtorch/.active` (single-host default).
122                worker
123                    .arch
124                    .as_ref()
125                    .map(|a| PathBuf::from(&worker.path).join("libtorch").join(a)),
126                worker.docker.clone(),
127                data_path_explicit,
128            )
129        } else {
130            probe_remote_via_ssh(worker, skip_mount)
131        };
132        reports.push(r);
133    }
134    let any_red = reports.iter().any(|r| !r.green());
135    if json {
136        print_cluster_json(&reports);
137    } else {
138        print_cluster_report(&reports);
139    }
140    if any_red { 1 } else { 0 }
141}
142
143/// SSH to `host` and run `fdl probe --json` there. The remote `fdl`
144/// is invoked bare and resolved by the remote shell's PATH (each host
145/// owns its own `fdl` install; the controller does not reach into the
146/// remote's build tree). Returns a synthetic `ProbeReport` carrying any
147/// SSH/parse failure in `issues` when the remote call fails — caller
148/// treats those as red verdicts.
149fn probe_remote_via_ssh(worker: &ClusterWorker, skip_mount: bool) -> ProbeReport {
150    let ssh_target = worker
151        .ssh
152        .as_ref()
153        .and_then(|s| s.target.as_deref())
154        .unwrap_or(&worker.host)
155        .to_string();
156    // Invoke bare `fdl` and rely on the remote shell's PATH. Each
157    // host owns its fdl install (typically `cargo install flodl-cli`
158    // into ~/.cargo/bin or ~/.local/bin); the controller does not
159    // reach into the remote's build tree. If a host lacks `fdl` on
160    // PATH the SSH command returns "fdl: command not found" exit
161    // 127, which the probe-result parser surfaces as an SSH error
162    // for that host.
163    let mut remote_args: Vec<String> = vec!["fdl".into(), "probe".into(), "--json".into()];
164    // Only forward --data-path when the host declared one. Without it,
165    // the remote falls back to DEFAULT_DATA_PATH and the probe treats a
166    // missing path as a WARNING (convention default) rather than an
167    // ERROR (explicit promise the user made in cluster.yml).
168    if let Some(dp) = &worker.data_path {
169        remote_args.push("--data-path".into());
170        remote_args.push(dp.clone());
171    }
172    if skip_mount {
173        remote_args.push("--skip-mount".into());
174    }
175    // Pass the host's libtorch path to the remote probe so the worker
176    // doesn't have to discover libtorch from its filesystem. Derived
177    // from the convention `<worker.path>/libtorch/<worker.arch>` when
178    // arch is declared; otherwise omitted, and the remote probe walks
179    // `<worker.path>/libtorch/.active` (single-host default).
180    if let Some(arch) = &worker.arch {
181        remote_args.push("--libtorch-path".into());
182        remote_args.push(format!(
183            "{path}/libtorch/{arch}",
184            path = worker.path.trim_end_matches('/'),
185        ));
186    }
187    // Pass the host's docker: compose service. Tells the remote probe
188    // that NCCL ships inside the container image, so it should report
189    // "via Docker image <svc>" instead of scanning host library paths.
190    if let Some(svc) = &worker.docker {
191        remote_args.push("--docker".into());
192        remote_args.push(svc.clone());
193    }
194    // Quote each remote arg into a single shell-safe command string
195    // (paths and options may contain spaces / metacharacters).
196    let quoted = remote_args
197        .iter()
198        .map(|a| crate::util::shell::posix_quote(a))
199        .collect::<Vec<_>>()
200        .join(" ");
201    // cd into the remote host's project path BEFORE invoking fdl so
202    // `Context::resolve()` walks up from there + finds the shared
203    // libtorch/.active. Without the cd, fdl walks from the SSH login
204    // dir (typically ~) and either misses the project root or
205    // resolves a stale local fdl install.
206    let remote_cmd = format!(
207        "cd {} && {quoted}",
208        crate::util::shell::posix_quote(&worker.path),
209    );
210
211    // Honor the worker's `ssh:` sub-block (port / user / identity_file /
212    // options) just like the cluster dispatch path — otherwise a
213    // Docker-container rank on `127.0.0.1:2222` with an identity_file is
214    // dialed on the default port 22 and the connect is refused (the
215    // probe then reports the host red even though dispatch works fine).
216    let mut cmd = Command::new("ssh");
217    // User ssh.options first (they win), then flodl's defaults (M17).
218    crate::cluster::apply_worker_ssh_opts(&mut cmd, worker);
219    cmd.args([
220        "-T",
221        "-o",
222        "BatchMode=yes",
223        "-o",
224        "ServerAliveInterval=10",
225        "-o",
226        "ServerAliveCountMax=3",
227    ]);
228    cmd.arg(&ssh_target).arg(&remote_cmd);
229    let output = cmd.output();
230
231    let mut report = ProbeReport {
232        host: worker.host.clone(),
233        gpus: Vec::new(),
234        libtorch: LibtorchStatus {
235            info: None,
236            valid_dir: false,
237            archs_match: Vec::new(),
238        },
239        data_path: DataPathStatus {
240            path: PathBuf::from(worker.effective_data_path()),
241            exists: false,
242            readable: false,
243            fs_type: None,
244            skipped: skip_mount,
245        },
246        nccl: NcclStatus {
247            library_path: None,
248            all_found: Vec::new(),
249            via_docker: worker.docker.clone(),
250        },
251        issues: Vec::new(),
252        warnings: Vec::new(),
253    };
254    match output {
255        Err(e) => {
256            report.issues.push(format!(
257                "ssh to `{ssh_target}` failed before probe ran: {e}"
258            ));
259        }
260        Ok(out) => {
261            // The remote probe returns exit 1 when it found issues —
262            // that's the SAME signal the remote report carries via
263            // its own `issues` field. Don't treat it as fatal here;
264            // try to parse stdout regardless. Only fall back to a
265            // synthetic SSH-error report when parse actually fails.
266            let stdout = String::from_utf8_lossy(&out.stdout);
267            match parse_remote_json(&stdout, worker) {
268                Ok(r) => report = r,
269                Err(parse_err) => {
270                    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
271                    report.issues.push(format!(
272                        "remote probe on `{ssh_target}` exited {} — \
273                         stdout did not parse as JSON ({parse_err}); \
274                         stderr: {stderr}; first 200 chars of stdout: {:?}",
275                        out.status,
276                        stdout.chars().take(200).collect::<String>(),
277                    ));
278                }
279            }
280        }
281    }
282    report
283}
284
285/// Parse the remote `fdl probe --json` output back into a
286/// [`ProbeReport`]. Minimal parser — pulls the fields the report
287/// formatter needs and trusts the remote produced what it produces.
288/// `host` is the cluster.yml entry; used to fill the `host` field of
289/// the report so name matches the topology (the remote returns its
290/// `hostname(1)`, which may differ from the cluster.yml name and is
291/// the more common source of "probe says host X but cluster.yml says
292/// host Y" diagnostics).
293fn parse_remote_json(json: &str, worker: &ClusterWorker) -> Result<ProbeReport, String> {
294    let v: serde_json::Value =
295        serde_json::from_str(json.trim()).map_err(|e| format!("JSON parse: {e}"))?;
296
297    let mut report = ProbeReport {
298        host: worker.host.clone(),
299        gpus: Vec::new(),
300        libtorch: LibtorchStatus {
301            info: None,
302            valid_dir: false,
303            archs_match: Vec::new(),
304        },
305        data_path: DataPathStatus {
306            path: PathBuf::from(worker.effective_data_path()),
307            exists: false,
308            readable: false,
309            fs_type: None,
310            skipped: false,
311        },
312        nccl: NcclStatus {
313            library_path: None,
314            all_found: Vec::new(),
315            via_docker: worker.docker.clone(),
316        },
317        issues: Vec::new(),
318        warnings: Vec::new(),
319    };
320
321    if let Some(gpus) = v.get("gpus").and_then(|g| g.as_array()) {
322        for g in gpus {
323            let index = g.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
324            let name = g
325                .get("name")
326                .and_then(|v| v.as_str())
327                .unwrap_or("")
328                .to_string();
329            let total_memory_mb = g.get("vram_mb").and_then(|v| v.as_u64()).unwrap_or(0);
330            // `vendor` + `arch` are the vendor-plural pair. `sm` is the
331            // legacy NVIDIA-only key, still read so a probe against an
332            // older remote fdl keeps working.
333            let vendor = g
334                .get("vendor")
335                .and_then(|v| v.as_str())
336                .and_then(GpuVendor::parse)
337                .unwrap_or(GpuVendor::Nvidia);
338            let token = g
339                .get("arch")
340                .and_then(|v| v.as_str())
341                .or_else(|| g.get("sm").and_then(|v| v.as_str()))
342                .unwrap_or_default();
343            let Some(arch) = GpuArch::parse(vendor, token) else {
344                // A device we cannot place is worse than one we drop: an
345                // unparsed arch would silently compare as incompatible
346                // against every libtorch variant. Say so instead.
347                report.warnings.push(format!(
348                    "host {:?}: GPU {index} reports an unrecognized {vendor} arch \
349                     {token:?}; skipping it in the report",
350                    worker.host,
351                ));
352                continue;
353            };
354            report.gpus.push(GpuInfo {
355                index,
356                vendor,
357                name,
358                arch,
359                total_memory_mb,
360            });
361        }
362    }
363
364    if let Some(lt) = v.get("libtorch")
365        && !lt.is_null()
366    {
367        let path = lt
368            .get("path")
369            .and_then(|v| v.as_str())
370            .unwrap_or("")
371            .to_string();
372        let valid_dir = lt
373            .get("valid_dir")
374            .and_then(|v| v.as_bool())
375            .unwrap_or(false);
376        let info = LibtorchInfo {
377            path,
378            torch_version: lt.get("torch").and_then(|v| v.as_str()).map(String::from),
379            cuda_version: lt.get("cuda").and_then(|v| v.as_str()).map(String::from),
380            archs: lt.get("archs").and_then(|v| v.as_str()).map(String::from),
381            source: None,
382        };
383        let mut archs_match = Vec::new();
384        if let Some(am) = lt.get("archs_match").and_then(|v| v.as_array()) {
385            for entry in am {
386                let gpu = entry.get("gpu").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
387                let covered = entry
388                    .get("covered")
389                    .and_then(|v| v.as_bool())
390                    .unwrap_or(false);
391                archs_match.push((gpu, covered));
392            }
393        }
394        report.libtorch = LibtorchStatus {
395            info: Some(info),
396            valid_dir,
397            archs_match,
398        };
399    }
400
401    if let Some(dp) = v.get("data_path") {
402        if !dp.is_null() {
403            let path = dp
404                .get("path")
405                .and_then(|v| v.as_str())
406                .map(PathBuf::from)
407                .unwrap_or_else(|| PathBuf::from(worker.effective_data_path()));
408            let exists = dp.get("exists").and_then(|v| v.as_bool()).unwrap_or(false);
409            let readable = dp
410                .get("readable")
411                .and_then(|v| v.as_bool())
412                .unwrap_or(false);
413            let fs_type = dp.get("fs_type").and_then(|v| v.as_str()).map(String::from);
414            report.data_path = DataPathStatus {
415                path,
416                exists,
417                readable,
418                fs_type,
419                skipped: false,
420            };
421        } else {
422            report.data_path.skipped = true;
423        }
424    }
425
426    if let Some(nccl) = v.get("nccl")
427        && !nccl.is_null()
428    {
429        let p = nccl
430            .get("library_path")
431            .and_then(|v| v.as_str())
432            .map(PathBuf::from);
433        report.nccl.library_path = p.clone();
434        if let Some(p) = p {
435            report.nccl.all_found.push(p);
436        }
437        // Prefer the remote's reported via_docker over the
438        // cluster.yml field — the controller already passed it in
439        // via --docker so the remote echo confirms what was used;
440        // they should match, and using the remote's keeps the
441        // round-trip a single source of truth.
442        if let Some(svc) = nccl.get("via_docker").and_then(|v| v.as_str()) {
443            report.nccl.via_docker = Some(svc.to_string());
444        }
445    }
446
447    if let Some(issues) = v.get("issues").and_then(|v| v.as_array()) {
448        for i in issues {
449            if let Some(s) = i.as_str() {
450                report.issues.push(s.to_string());
451            }
452        }
453    }
454    if let Some(warnings) = v.get("warnings").and_then(|v| v.as_array()) {
455        for w in warnings {
456            if let Some(s) = w.as_str() {
457                report.warnings.push(s.to_string());
458            }
459        }
460    }
461
462    // Shape guard: the current emitter always writes these keys. Their
463    // complete absence means the remote fdl speaks a different probe
464    // schema (version skew) — surface that instead of letting the lenient
465    // per-field defaults masquerade as "no GPUs" / "not ready".
466    for key in ["gpus", "ready"] {
467        if v.get(key).is_none() {
468            report.issues.push(format!(
469                "remote probe JSON has no {key:?} field — the remote fdl \
470                 likely speaks a different probe schema (version skew); \
471                 update fdl on `{}`",
472                worker.host
473            ));
474        }
475    }
476
477    Ok(report)
478}
479
480// ---------------------------------------------------------------------------
481// Cluster output
482// ---------------------------------------------------------------------------
483
484fn print_cluster_report(reports: &[ProbeReport]) {
485    println!("floDl Cluster Probe — {} hosts", reports.len());
486    println!("{}", "=".repeat(40));
487    println!();
488    for (i, r) in reports.iter().enumerate() {
489        if i > 0 {
490            println!();
491            println!("{}", "-".repeat(40));
492            println!();
493        }
494        print_report(r);
495    }
496    println!();
497    let red = reports.iter().filter(|r| !r.green()).count();
498    let yellow = reports
499        .iter()
500        .filter(|r| r.green() && !r.warnings.is_empty())
501        .count();
502    let total = reports.len();
503    match (red, yellow) {
504        (0, 0) => println!("CLUSTER VERDICT: READY (all {total} hosts green)"),
505        (0, y) => println!("CLUSTER VERDICT: READY ({y}/{total} hosts have warnings)"),
506        (r, 0) => println!("CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors)"),
507        (r, y) => println!(
508            "CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors, \
509             {y} also have warnings)"
510        ),
511    }
512}
513
514fn print_cluster_json(reports: &[ProbeReport]) {
515    let mut b = String::with_capacity(4096);
516    b.push_str("{\"hosts\":[");
517    for (i, r) in reports.iter().enumerate() {
518        if i > 0 {
519            b.push(',');
520        }
521        b.push_str(&report_to_json_object(r));
522    }
523    b.push(']');
524    let red = reports.iter().filter(|r| !r.green()).count();
525    let _ = write!(b, ",\"hosts_total\":{}", reports.len());
526    let _ = write!(b, ",\"hosts_red\":{}", red);
527    let _ = write!(b, ",\"ready\":{}", red == 0);
528    b.push('}');
529    println!("{}", b);
530}
531
532// ---------------------------------------------------------------------------
533// Report structs
534// ---------------------------------------------------------------------------
535
536/// Top-level probe verdict for one host. `green()` is the aggregate
537/// gate.
538///
539/// `issues` are blocking errors (exit non-zero); `warnings` are advisory
540/// (exit zero, surfaced in the report). The split matters because
541/// "/flodl/data missing" on a single-host rig that doesn't use shared
542/// storage is informational, while a worker host that declared an
543/// explicit `data_path:` in cluster.yml and can't see it is broken.
544pub struct ProbeReport {
545    pub host: String,
546    pub gpus: Vec<GpuInfo>,
547    pub libtorch: LibtorchStatus,
548    pub data_path: DataPathStatus,
549    pub nccl: NcclStatus,
550    pub issues: Vec<String>,
551    pub warnings: Vec<String>,
552}
553
554impl ProbeReport {
555    /// `true` when no issues were collected — every checked component
556    /// passed (warnings do NOT flip this). Callers may still want to
557    /// inspect individual statuses for diagnostic detail; the exit
558    /// code follows this flag.
559    pub fn green(&self) -> bool {
560        self.issues.is_empty()
561    }
562}
563
564/// libtorch directory + arch metadata + per-GPU compatibility verdict.
565pub struct LibtorchStatus {
566    /// Parsed `.arch` metadata (if libtorch is present + readable).
567    pub info: Option<LibtorchInfo>,
568    /// `lib/` subdirectory present (cheap "is this a libtorch dir?"
569    /// check that doesn't require parsing).
570    pub valid_dir: bool,
571    /// Per-GPU `(gpu_index, archs_cover_this_gpu)`. Empty when libtorch
572    /// is missing.
573    pub archs_match: Vec<(u8, bool)>,
574}
575
576/// Shared-data path visibility + filesystem-type detection.
577pub struct DataPathStatus {
578    pub path: PathBuf,
579    pub exists: bool,
580    pub readable: bool,
581    /// Underlying filesystem type from `/proc/mounts` (e.g. `virtiofs`,
582    /// `nfs4`, `cifs`, `fuse.sshfs`, `ext4`). `None` when the path is
583    /// not mounted (falls inside the parent FS) or when /proc/mounts
584    /// is unavailable.
585    pub fs_type: Option<String>,
586    /// `true` when the check was explicitly bypassed via
587    /// `--skip-mount`; the path/exists fields are unset (`PathBuf::new`
588    /// + false) in that case.
589    pub skipped: bool,
590}
591
592/// NCCL discovery result. NCCL is loaded dynamically by libtorch, so
593/// the probe just hunts for `libnccl.so*` on the usual library paths
594/// — unless [`Self::via_docker`] is set, in which case NCCL ships
595/// inside the container image and the host scan is skipped.
596pub struct NcclStatus {
597    /// First `libnccl.so*` found, if any. Used in the report to show
598    /// the user which install will be picked up.
599    pub library_path: Option<PathBuf>,
600    /// All discovered `libnccl.so*` paths (informational; multiple
601    /// versions in different prefixes is a misconfiguration source).
602    pub all_found: Vec<PathBuf>,
603    /// Docker compose service that owns NCCL on this host. When set,
604    /// the probe records "via Docker image `<svc>`" instead of scanning
605    /// the host filesystem. `None` means the host runs flodl natively
606    /// and NCCL must live on it.
607    pub via_docker: Option<String>,
608}
609
610// ---------------------------------------------------------------------------
611// Single-host probe
612// ---------------------------------------------------------------------------
613
614/// Probe the local host. `data_path_override` (from `--data-path` CLI
615/// flag) overrides config; `skip_mount` short-circuits the shared-data
616/// check (useful for single-host setups without a shared FS
617/// configured); `libtorch_path_override` (from `--libtorch-path`)
618/// points at a libtorch install outside the project tree (used by
619/// cluster-mode remote probes where libtorch lives on a dedicated
620/// share like `/mnt/libtorch`). `via_docker` (from `--docker <svc>` or
621/// the cluster.yml host's `docker:` field) tells the probe NCCL ships
622/// inside a container image, so host-level NCCL scanning is replaced
623/// by an informational "via Docker image `<svc>`" line.
624///
625/// `data_path_explicit`: when `true`, a missing shared-data path is an
626/// ERROR (the user/cluster.yml promised it); when `false`, it's a
627/// WARNING (the convention default was used). Internal flag — callers
628/// must derive it from "did the caller pass an explicit data_path".
629pub fn probe_local(
630    ctx: &Context,
631    skip_mount: bool,
632    data_path_override: Option<PathBuf>,
633    libtorch_path_override: Option<PathBuf>,
634    via_docker: Option<String>,
635    data_path_explicit: bool,
636) -> ProbeReport {
637    let host = resolve_local_hostname();
638    let mut issues: Vec<String> = Vec::new();
639    let mut warnings: Vec<String> = Vec::new();
640
641    // The full sweep, not just its device list. A survey's findings are
642    // the part a device list cannot express, and the case that matters
643    // most for a second vendor has NO device at all: a card physically
644    // present whose stack is not installed. `probe` exists to tell an
645    // operator why a host is not ready, so it is the one command that
646    // must never drop them.
647    let sweep = flodl_hw::survey();
648    for note in &sweep.notes {
649        if note.kind.explains_absence() {
650            issues.push(note.to_string());
651        } else {
652            warnings.push(note.to_string());
653        }
654    }
655    // Read the vendor facts before `devices` is moved out.
656    //
657    // The NCCL scan looks for `libnccl.so`, an NVIDIA artifact, so it is
658    // only meaningful when this host actually has an NVIDIA GPU. On an
659    // AMD host the collective library is RCCL, which ships INSIDE
660    // libtorch-rocm's own `lib/`; on a GPU-less host nothing collective
661    // can run at all, and the "no usable GPUs" issue below already says
662    // so. Either way "Install libnccl matching your CUDA version" points
663    // the operator at the wrong thing.
664    //
665    // Note this reads the PHYSICAL sweep, not the masked one, so a rig
666    // whose GPUs are temporarily hidden by CUDA_VISIBLE_DEVICES still
667    // gets its NCCL install checked.
668    let has_nvidia = sweep.has_vendor(GpuVendor::Nvidia);
669    let gpus = sweep.devices;
670
671    let libtorch = match libtorch_path_override {
672        Some(p) => check_libtorch_at(&p, &gpus, &mut issues),
673        None => check_libtorch(&ctx.root, &gpus, &mut issues),
674    };
675    let data_path = check_data_path(
676        data_path_override.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_PATH)),
677        skip_mount,
678        data_path_explicit,
679        &mut issues,
680        &mut warnings,
681    );
682    // The NCCL scan looks for `libnccl.so`, which is an NVIDIA artifact.
683    // AMD's collective library is RCCL, and it ships INSIDE
684    // libtorch-rocm's own `lib/` -- so on an AMD-only host there is
685    // nothing to discover and a "libnccl not found" issue would be pure
686    // noise telling the operator to install the wrong thing.
687    //
688    // The asymmetry is the distributions', not ours, and it is measured:
689    // the published 2.10.0+rocm7.0 archive carries `lib/librccl.so`
690    // (~340 MB), while the CUDA archives bundle no libnccl at all, which
691    // is exactly why that one is worth probing for and this one is not.
692    let nccl = if !has_nvidia {
693        NcclStatus {
694            library_path: None,
695            all_found: vec![],
696            via_docker: None,
697        }
698    } else {
699        check_nccl(via_docker, &mut issues)
700    };
701
702    if gpus.is_empty() {
703        // Say what was actually looked for. The old text named
704        // nvidia-smi unconditionally, which is simply false on a host
705        // whose GPU is AMD -- and that host is exactly the one whose
706        // operator most needs an accurate message. Any vendor-specific
707        // reason already rode in as a survey note above.
708        issues.push(
709            "no usable GPUs detected. Single-host CPU training will still \
710             work; multi-rank training requires a working GPU stack."
711                .into(),
712        );
713    }
714
715    check_gpu_toolkit(libtorch.info.as_ref(), &mut warnings);
716
717    // Host tools are a hard issue: without them `fdl` cannot download or
718    // unpack anything, whatever the build strategy.
719    let tools = requirements::missing_host_tools();
720    if !tools.is_empty() {
721        issues.push(format!(
722            "missing host tools `fdl` needs: {}. Install with `sudo apt install {}` \
723             (or the equivalent for your distribution).",
724            tools.join(", "),
725            tools.join(" "),
726        ));
727    }
728
729    ProbeReport {
730        host,
731        gpus,
732        libtorch,
733        data_path,
734        nccl,
735        issues,
736        warnings,
737    }
738}
739
740/// Build a [`LibtorchStatus`] from a resolved [`LibtorchInfo`] (or
741/// `None` when the pointer could not be resolved). Used by the
742/// pointer-file shape of [`check_libtorch_at`]; mirrors the
743/// arch-check and valid-dir logic from [`check_libtorch`] without
744/// duplicating its `.active` walk.
745/// Report a variant the dynamic linker cannot satisfy on this host.
746///
747/// A libtorch archive is built against some baseline C library and the
748/// baseline differs per variant: measured on 2.10.0, cpu and cu128 want
749/// `GLIBC_2.29` while rocm7.0 wants `GLIBC_2.35`. RHEL 9 ships 2.34 and
750/// cannot go further, so that pair compiles, links, and then dies in the
751/// loader quoting symbol versions. Naming it here costs one `ldd`.
752///
753/// Called from every arm that produces a [`LibtorchStatus`]: the first
754/// version of this check lived in one of them, and the explicit
755/// `--libtorch-path` arm builds its status inline, so a real RHEL box
756/// reported nothing at all.
757fn push_loader_issue(variant_dir: &Path, label: &str, issues: &mut Vec<String>) {
758    let unmet = detect::unmet_loader_requirements(variant_dir);
759    if unmet.is_empty() {
760        return;
761    }
762    issues.push(format!(
763        "libtorch variant `{label}` cannot load on this host: the dynamic \
764         linker is missing {}. The archive was built against a newer C \
765         library than this distribution ships, so it compiles and links and \
766         then fails to start. Use a variant with an older baseline (cpu and \
767         cu128 need less than the rocm archives) or a newer distribution.",
768        unmet.join(", "),
769    ));
770}
771
772fn libtorch_status_from_info(
773    info: Option<LibtorchInfo>,
774    libtorch_root: &Path,
775    gpus: &[GpuInfo],
776    issues: &mut Vec<String>,
777) -> LibtorchStatus {
778    let valid_dir = match &info {
779        Some(i) => libtorch_root.join(&i.path).join("lib").is_dir(),
780        None => false,
781    };
782    if let Some(i) = &info {
783        push_loader_issue(&libtorch_root.join(&i.path), &i.path, issues);
784    }
785    let archs_match = match &info {
786        Some(i) => detect::arch_coverage(i, gpus, issues),
787        None => {
788            issues.push(
789                "libtorch pointer file did not resolve to a configured \
790                 variant (file empty or missing). Check the `.active*` \
791                 content names a real subdir under `libtorch/`."
792                    .into(),
793            );
794            Vec::new()
795        }
796    };
797    LibtorchStatus {
798        info,
799        valid_dir,
800        archs_match,
801    }
802}
803
804/// Variant that takes an explicit libtorch path instead of walking
805/// from the project root. Accepts three shapes:
806///
807/// 1. **Libtorch ROOT** (dir containing `.active` + `builds/` /
808///    `precompiled/`) — delegates to [`check_libtorch`] which walks
809///    `.active`.
810/// 2. **Pointer file** (file path ending in `.active*`, e.g.
811///    `libtorch/.active.blackwell`) — reads the pointer and resolves
812///    the variant relative to the file's parent directory. Used for
813///    heterogeneous rigs where each host's `cluster.yml` entry sets
814///    `arch:` to a different case-file subpath (e.g. `.active.blackwell`).
815/// 3. **Direct variant dir** (has `lib/libtorch.so` + optional
816///    `.arch`) — used as-is.
817fn check_libtorch_at(path: &Path, gpus: &[GpuInfo], issues: &mut Vec<String>) -> LibtorchStatus {
818    // Shape 2: a regular file whose name starts with `.active` is a
819    // pointer to a variant subdir. Resolve relative to the file's
820    // parent (the libtorch root). Note: `.active` itself is also a
821    // file but Shape 1 catches it via dir-containing-.active above.
822    if path.is_file()
823        && path
824            .file_name()
825            .and_then(|n| n.to_str())
826            .is_some_and(|n| n.starts_with(".active"))
827    {
828        let libtorch_root = path.parent().unwrap_or(path);
829        let info = detect::read_active_from(path, libtorch_root);
830        return libtorch_status_from_info(info, libtorch_root, gpus, issues);
831    }
832    if path.join(".active").exists() {
833        return check_libtorch(path, gpus, issues);
834    }
835    let dir = path;
836    let valid_dir = dir.join("lib").is_dir();
837    if !valid_dir {
838        issues.push(format!(
839            "libtorch directory `{}` does not contain `lib/` — pass \
840             `--libtorch-path` pointing at a real libtorch install \
841             (the directory with `lib/libtorch.so`).",
842            dir.display()
843        ));
844        return LibtorchStatus {
845            info: None,
846            valid_dir: false,
847            archs_match: Vec::new(),
848        };
849    }
850    let info = detect::libtorch_info_from_dir(dir.display().to_string(), dir);
851    let archs_match = detect::arch_coverage(&info, gpus, issues);
852    push_loader_issue(dir, &info.path, issues);
853    LibtorchStatus {
854        info: Some(info),
855        valid_dir: true,
856        archs_match,
857    }
858}
859
860fn check_libtorch(root: &Path, gpus: &[GpuInfo], issues: &mut Vec<String>) -> LibtorchStatus {
861    // `root` can be the project root OR the libtorch root (latter is
862    // what `--libtorch-path /path/to/libtorch` resolves to when the
863    // dir has `.active`). `read_active` expects the parent of
864    // `libtorch/`; if `root` is itself a libtorch root (has `.active`
865    // directly under it), reframe.
866    let info = if root.join(".active").exists() {
867        // Synthesize the parent + variant path, then call read_active
868        // with a synthetic parent that exposes `libtorch/.active`.
869        let active_text = std::fs::read_to_string(root.join(".active")).ok();
870        match active_text {
871            Some(t) => {
872                let variant = t.trim().to_string();
873                if variant.is_empty() {
874                    None
875                } else {
876                    let arch_dir = root.join(&variant);
877                    Some(detect::libtorch_info_from_dir(variant, &arch_dir))
878                }
879            }
880            None => None,
881        }
882    } else {
883        detect::read_active(root)
884    };
885    let valid_dir = match &info {
886        Some(i) => {
887            if root.join(".active").exists() {
888                root.join(&i.path).join("lib").is_dir()
889            } else {
890                detect::is_valid_variant(root, &i.path)
891            }
892        }
893        None => false,
894    };
895
896    let archs_match = match &info {
897        Some(i) => detect::arch_coverage(i, gpus, issues),
898        None => {
899            issues.push(
900                "libtorch not configured — `libtorch/.active` missing or \
901                 empty. Run `fdl libtorch download` or `fdl libtorch build` \
902                 to provision a variant."
903                    .into(),
904            );
905            Vec::new()
906        }
907    };
908
909    LibtorchStatus {
910        info,
911        valid_dir,
912        archs_match,
913    }
914}
915
916fn check_data_path(
917    path: PathBuf,
918    skip_mount: bool,
919    explicit: bool,
920    issues: &mut Vec<String>,
921    warnings: &mut Vec<String>,
922) -> DataPathStatus {
923    if skip_mount {
924        return DataPathStatus {
925            path: PathBuf::new(),
926            exists: false,
927            readable: false,
928            fs_type: None,
929            skipped: true,
930        };
931    }
932    let exists = path.exists();
933    let readable = exists && std::fs::read_dir(&path).is_ok();
934    let fs_type = detect_fs_type(&path);
935
936    if !exists {
937        if explicit {
938            // The user (or cluster.yml) promised this path. Missing it
939            // is a launch-breaking error — training fan-out would
940            // discover this mid-run when a checkpoint write hangs.
941            issues.push(format!(
942                "shared data path `{}` does not exist on this host. flodl \
943                 assumes a shared filesystem (NAS / SMB / virtiofs / SSHFS) \
944                 mounted at the same logical path on every node. Mount the \
945                 shared storage or correct `data_path:` in cluster.yml.",
946                path.display()
947            ));
948        } else {
949            // No explicit path was declared — the convention default
950            // `/flodl/data` was tried. Missing it is fine for users who
951            // don't use shared storage; surface it as a warning so they
952            // know the default isn't wired up.
953            warnings.push(format!(
954                "convention shared-data path `{}` not present on this host \
955                 (no `data_path:` declared in cluster.yml). Ignore if you \
956                 don't use shared storage; otherwise set `data_path:` per \
957                 host or mount `{}`.",
958                path.display(),
959                path.display()
960            ));
961        }
962    } else if !readable {
963        issues.push(format!(
964            "shared data path `{}` exists but is not readable by the \
965             current user. Check mount permissions / uid mapping.",
966            path.display()
967        ));
968    }
969
970    DataPathStatus {
971        path,
972        exists,
973        readable,
974        fs_type,
975        skipped: false,
976    }
977}
978
979/// Report a missing vendor toolkit for the ACTIVE libtorch variant.
980///
981/// The active variant is what declares intent: `precompiled/rocm70` says
982/// this project builds ROCm, so it will need HIP headers. That is the
983/// same signal `$FDL_GPU_FEATURE` is derived from, so the two cannot
984/// disagree about which vendor is in play.
985///
986/// Only headers are checked: libtorch bundles every library the link
987/// needs, so headers are the whole gap.
988///
989/// A warning rather than an issue: the default workflow builds in the
990/// dev container, where host headers are irrelevant. It applies to
991/// native builds, and the text says so.
992///
993/// `flodl-sys/build.rs` guards the same requirement at compile time;
994/// this reports it before a build is attempted.
995fn check_gpu_toolkit(info: Option<&LibtorchInfo>, warnings: &mut Vec<String>) {
996    let Some(info) = info else { return };
997    let Some(vendor) = detect::variant_vendor(&info.path) else {
998        return; // CPU variant: no toolkit to want.
999    };
1000
1001    // `GpuVendor` is #[non_exhaustive] on purpose -- Intel is the planned
1002    // third. A vendor with no entry here has no known toolkit layout, and
1003    // guessing one would produce a confidently wrong apt command. Say
1004    // nothing until someone adds real facts.
1005    //
1006    // The header tables are `util::requirements`'s — the SAME set
1007    // flodl-sys/build.rs demands, covering the whole include chain. A
1008    // shorter hand-picked list here is the trap this replaced: probe
1009    // reports clean, the operator proceeds, and the build fails on a
1010    // header the short list never looked for. ROCm has no metapackage,
1011    // so its install line must name every package; `cuda-toolkit` IS a
1012    // metapackage, so the NVIDIA line stays that plus libnccl-dev
1013    // rather than version-placeholder package names.
1014    let plan = match vendor {
1015        GpuVendor::Amd => Some((
1016            "ROCM_PATH",
1017            flodl_hw::rocm_runtime_root()
1018                .map(|p| p.display().to_string())
1019                .or_else(|| std::env::var("ROCM_PATH").ok())
1020                .unwrap_or_else(|| "/opt/rocm".to_string()),
1021            crate::util::requirements::ROCM_HEADERS,
1022            None,
1023            "rocm",
1024        )),
1025        GpuVendor::Nvidia => Some((
1026            "CUDA_HOME",
1027            std::env::var("CUDA_HOME").unwrap_or_else(|_| "/usr/local/cuda".to_string()),
1028            crate::util::requirements::CUDA_HEADERS,
1029            Some("cuda-toolkit libnccl-dev"),
1030            "cuda",
1031        )),
1032        _ => None,
1033    };
1034    let Some((root_env, root, headers, metapackages, feature)) = plan else {
1035        return;
1036    };
1037
1038    if let Some(w) = gpu_toolkit_warning(
1039        &info.path,
1040        Path::new(&root),
1041        root_env,
1042        headers,
1043        metapackages,
1044        feature,
1045    ) {
1046        warnings.push(w);
1047    }
1048}
1049
1050/// Pure core of [`check_gpu_toolkit`]: the toolkit root is a parameter,
1051/// not an env read, so every arm is testable without mutating
1052/// process-global state. That matters more than usual here -- this
1053/// crate's test binary runs in parallel, and an env-mutating test only
1054/// works if every reader takes the same lock, which they do not.
1055///
1056/// `metapackages` overrides the per-header package list in the install
1057/// line, for the vendor whose metapackage covers the set.
1058fn gpu_toolkit_warning(
1059    variant: &str,
1060    root: &Path,
1061    root_env: &str,
1062    headers: &[(&str, &str)],
1063    metapackages: Option<&str>,
1064    feature: &str,
1065) -> Option<String> {
1066    let missing = crate::util::requirements::missing_headers(root, headers);
1067    if missing.is_empty() {
1068        return None;
1069    }
1070    let packages: Vec<String> = match metapackages {
1071        Some(m) => m.split_whitespace().map(str::to_string).collect(),
1072        None => crate::util::requirements::packages_for(&missing),
1073    };
1074    let list: Vec<&str> = missing.iter().map(|(h, _)| *h).collect();
1075    let root = root.display();
1076    // Through `install_hint`, so the command names this family's package
1077    // manager and its own spelling of the packages. Hardcoding apt here
1078    // told a RHEL box to run `sudo apt install hip-dev`, which is two
1079    // kinds of wrong at once.
1080    let install = crate::util::requirements::install_hint(&packages);
1081    // No backticks around the install line: it carries its own trailing
1082    // caveat ("or your distribution's equivalent"), and quoting the pair
1083    // as one span invites a copy-paste that dnf rejects on the paren.
1084    Some(format!(
1085        "active libtorch is `{}` but its toolkit headers are missing under \
1086         `{root}` ({}). Native builds with `--features {feature}` will fail; \
1087         building in the dev container is unaffected. Install them with: \
1088         {install}. Set {root_env} if your install is elsewhere.",
1089        variant,
1090        list.join(", "),
1091    ))
1092}
1093
1094fn check_nccl(via_docker: Option<String>, issues: &mut Vec<String>) -> NcclStatus {
1095    // Docker-served host: NCCL lives inside the container image, not
1096    // on the host. Skip the host scan entirely — scanning would
1097    // false-positive on the false-error path that motivated the docker
1098    // field (host shows "no libnccl.so" while training actually runs
1099    // fine inside the cuda/dev image). Report as informational.
1100    if via_docker.is_some() {
1101        return NcclStatus {
1102            library_path: None,
1103            all_found: Vec::new(),
1104            via_docker,
1105        };
1106    }
1107
1108    let mut found: Vec<PathBuf> = Vec::new();
1109    // Common search locations. Order matters — first match wins for
1110    // the diagnostic `library_path` field.
1111    let candidates = [
1112        "/usr/lib/x86_64-linux-gnu",
1113        "/usr/local/lib",
1114        "/usr/local/cuda/lib64",
1115        "/opt/cuda/lib64",
1116    ];
1117    for dir in candidates {
1118        let d = Path::new(dir);
1119        if let Ok(entries) = std::fs::read_dir(d) {
1120            for entry in entries.flatten() {
1121                let name = entry.file_name();
1122                let s = name.to_string_lossy();
1123                if s.starts_with("libnccl.so") {
1124                    found.push(entry.path());
1125                }
1126            }
1127        }
1128    }
1129    // Honor LD_LIBRARY_PATH so user-shipped NCCL (the Pascal rig keeps
1130    // libnccl.so under ~/nccl/build/lib for the CUDA-13 source build)
1131    // is discovered.
1132    if let Ok(paths) = std::env::var("LD_LIBRARY_PATH") {
1133        for dir in paths.split(':').filter(|p| !p.is_empty()) {
1134            let d = Path::new(dir);
1135            if let Ok(entries) = std::fs::read_dir(d) {
1136                for entry in entries.flatten() {
1137                    let name = entry.file_name();
1138                    let s = name.to_string_lossy();
1139                    if s.starts_with("libnccl.so") {
1140                        let p = entry.path();
1141                        if !found.iter().any(|f| f == &p) {
1142                            found.push(p);
1143                        }
1144                    }
1145                }
1146            }
1147        }
1148    }
1149
1150    if found.is_empty() {
1151        issues.push(
1152            "no `libnccl.so` found on standard library paths or \
1153             $LD_LIBRARY_PATH. Multi-rank NCCL training will fail at \
1154             collective init. Install libnccl matching your CUDA \
1155             version or set LD_LIBRARY_PATH to a custom build (or \
1156             declare `docker:` on this host in cluster.yml if NCCL \
1157             ships inside the container image)."
1158                .into(),
1159        );
1160    }
1161
1162    NcclStatus {
1163        library_path: found.first().cloned(),
1164        all_found: found,
1165        via_docker: None,
1166    }
1167}
1168
1169/// What is mounted AT `path` exactly: `(source, fs_type)` from
1170/// `/proc/mounts`, e.g. `("flodl@exa:/flodl/data", "fuse.sshfs")`.
1171/// `None` when `path` is not itself a mount point — which is how
1172/// [`crate::prepare`] tells "already mounted, nothing to do" from "mount
1173/// it now" without shelling out to `mountpoint(1)`. Contrast
1174/// [`detect_fs_type`], which walks toward the root and therefore always
1175/// answers something.
1176pub(crate) fn mounted_at(path: &Path) -> Option<(String, String)> {
1177    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
1178    let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1179    // Last match wins: a mount point can be stacked, and the effective
1180    // filesystem is the one mounted most recently.
1181    let mut found = None;
1182    for line in mounts.lines() {
1183        let cols: Vec<&str> = line.split_whitespace().collect();
1184        if cols.len() >= 3 && Path::new(cols[1]) == abs {
1185            found = Some((unescape_mount(cols[0]), cols[2].to_string()));
1186        }
1187    }
1188    found
1189}
1190
1191/// `/proc/mounts` octal-escapes space, tab, newline and backslash in
1192/// the source and mount-point columns. Only the source is user-facing
1193/// here (it goes into a mismatch warning), and a path with a space in it
1194/// would otherwise print as `exa:/flodl\040data`.
1195fn unescape_mount(field: &str) -> String {
1196    let mut out = String::with_capacity(field.len());
1197    let mut chars = field.chars();
1198    while let Some(c) = chars.next() {
1199        if c != '\\' {
1200            out.push(c);
1201            continue;
1202        }
1203        let digits: String = chars.clone().take(3).collect();
1204        match u8::from_str_radix(&digits, 8) {
1205            Ok(byte) if digits.len() == 3 => {
1206                out.push(byte as char);
1207                for _ in 0..3 {
1208                    chars.next();
1209                }
1210            }
1211            _ => out.push(c),
1212        }
1213    }
1214    out
1215}
1216
1217/// Best-effort filesystem-type lookup via `/proc/mounts`. Walks toward
1218/// the root looking for the closest mount-point that contains `path`.
1219/// Returns `None` on non-Linux or when `/proc/mounts` is unavailable.
1220pub(crate) fn detect_fs_type(path: &Path) -> Option<String> {
1221    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
1222    let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1223    let mut best: Option<(usize, String)> = None;
1224    for line in mounts.lines() {
1225        let cols: Vec<&str> = line.split_whitespace().collect();
1226        if cols.len() < 3 {
1227            continue;
1228        }
1229        let mountpoint = Path::new(cols[1]);
1230        let fs_type = cols[2].to_string();
1231        if abs.starts_with(mountpoint) {
1232            let depth = mountpoint.components().count();
1233            match &best {
1234                Some((prev_depth, _)) if depth <= *prev_depth => {}
1235                _ => best = Some((depth, fs_type)),
1236            }
1237        }
1238    }
1239    best.map(|(_, t)| t)
1240}
1241
1242// ---------------------------------------------------------------------------
1243// Text output
1244// ---------------------------------------------------------------------------
1245
1246fn print_report(r: &ProbeReport) {
1247    println!("floDl Probe — {}", r.host);
1248    println!("{}", "=".repeat(40));
1249    println!();
1250
1251    println!("GPUs ({}):", r.gpus.len());
1252    for g in &r.gpus {
1253        println!(
1254            "  [{}] {} — {}, {} MB",
1255            g.index,
1256            g.short_name(),
1257            g.arch_label(),
1258            g.total_memory_mb
1259        );
1260    }
1261    println!();
1262
1263    println!("libtorch:");
1264    match &r.libtorch.info {
1265        Some(info) => {
1266            println!("  path  : {}", info.path);
1267            if let Some(t) = &info.torch_version {
1268                println!("  torch : {}", t);
1269            }
1270            // Same reason as `fdl diagnose`: `cuda=` is a CUDA toolkit
1271            // version, absent (`none`) on both ROCm and CPU builds, so
1272            // the vendor comes from the variant path instead. The JSON
1273            // arm below keeps emitting the raw `cuda` field -- it is
1274            // cluster wire format that remote hosts are parsed back out
1275            // of, so its shape is not a display decision.
1276            match detect::variant_vendor(&info.path) {
1277                Some(v) => println!("  vendor: {}", v),
1278                None => println!("  vendor: CPU-only"),
1279            }
1280            if let Some(c) = info.cuda_version.as_deref().filter(|c| *c != "none") {
1281                println!("  cuda  : {}", c);
1282            }
1283            if let Some(a) = &info.archs {
1284                println!("  archs : {}", a);
1285            }
1286            if !r.libtorch.archs_match.is_empty() {
1287                let ok = r.libtorch.archs_match.iter().filter(|(_, b)| *b).count();
1288                println!(
1289                    "  match : {}/{} GPUs covered",
1290                    ok,
1291                    r.libtorch.archs_match.len()
1292                );
1293            }
1294            println!(
1295                "  valid : {}",
1296                if r.libtorch.valid_dir { "yes" } else { "no" }
1297            );
1298        }
1299        None => println!("  (not configured)"),
1300    }
1301    println!();
1302
1303    println!("Shared data path:");
1304    if r.data_path.skipped {
1305        println!("  (skipped via --skip-mount)");
1306    } else {
1307        println!("  path     : {}", r.data_path.path.display());
1308        println!("  exists   : {}", yn(r.data_path.exists));
1309        println!("  readable : {}", yn(r.data_path.readable));
1310        if let Some(t) = &r.data_path.fs_type {
1311            println!("  fs       : {}", t);
1312        }
1313    }
1314    println!();
1315
1316    println!("NCCL:");
1317    if let Some(svc) = &r.nccl.via_docker {
1318        println!("  via Docker image `{}` (host check skipped)", svc);
1319    } else {
1320        match &r.nccl.library_path {
1321            Some(p) => {
1322                println!("  found    : {}", p.display());
1323                if r.nccl.all_found.len() > 1 {
1324                    println!(
1325                        "  others   : {} more (check for version skew)",
1326                        r.nccl.all_found.len() - 1
1327                    );
1328                }
1329            }
1330            None => println!("  (no libnccl.so* discovered)"),
1331        }
1332    }
1333    println!();
1334
1335    print_verdict_lines(&r.issues, &r.warnings);
1336}
1337
1338/// Render the three-tier verdict + numbered errors/warnings.
1339fn print_verdict_lines(issues: &[String], warnings: &[String]) {
1340    let n_err = issues.len();
1341    let n_warn = warnings.len();
1342    let line = match (n_err, n_warn) {
1343        (0, 0) => "verdict: READY".to_string(),
1344        (0, m) => format!("verdict: READY ({m} warning{})", plural(m)),
1345        (n, 0) => format!("verdict: ISSUES ({n} error{})", plural(n)),
1346        (n, m) => format!(
1347            "verdict: ISSUES ({n} error{}, {m} warning{})",
1348            plural(n),
1349            plural(m)
1350        ),
1351    };
1352    println!("{line}");
1353    if !issues.is_empty() {
1354        println!("errors:");
1355        for (i, msg) in issues.iter().enumerate() {
1356            println!("  {}. {}", i + 1, msg);
1357        }
1358    }
1359    if !warnings.is_empty() {
1360        println!("warnings:");
1361        for (i, msg) in warnings.iter().enumerate() {
1362            println!("  {}. {}", i + 1, msg);
1363        }
1364    }
1365}
1366
1367fn plural(n: usize) -> &'static str {
1368    if n == 1 { "" } else { "s" }
1369}
1370
1371fn yn(b: bool) -> &'static str {
1372    if b { "yes" } else { "no" }
1373}
1374
1375// ---------------------------------------------------------------------------
1376// JSON output (`fdl deploy` + CI consume this shape)
1377// ---------------------------------------------------------------------------
1378
1379fn print_json(r: &ProbeReport) {
1380    println!("{}", report_to_json_object(r));
1381}
1382
1383fn report_to_json_object(r: &ProbeReport) -> String {
1384    let mut b = String::with_capacity(2048);
1385    b.push('{');
1386    let _ = write!(b, "\"host\":\"{}\"", system::escape_json(&r.host));
1387
1388    // GPUs
1389    b.push_str(",\"gpus\":[");
1390    for (i, g) in r.gpus.iter().enumerate() {
1391        if i > 0 {
1392            b.push(',');
1393        }
1394        let _ = write!(
1395            b,
1396            "{{\"index\":{},\"name\":\"{}\",\"vendor\":\"{}\",\"arch\":\"{}\",\"sm\":\"{}\",\"vram_mb\":{}}}",
1397            g.index,
1398            system::escape_json(&g.name),
1399            g.vendor.as_str(),
1400            g.arch_label(),
1401            // Legacy NVIDIA-only key: an older `fdl` on the controller
1402            // side reads this one. Empty on a non-NVIDIA device, which
1403            // such a reader would have mis-handled anyway.
1404            g.sm_version().unwrap_or_default(),
1405            g.total_memory_mb
1406        );
1407    }
1408    b.push(']');
1409
1410    // libtorch
1411    b.push_str(",\"libtorch\":");
1412    match &r.libtorch.info {
1413        Some(info) => {
1414            let _ = write!(
1415                b,
1416                "{{\"path\":\"{}\",\"valid_dir\":{}",
1417                system::escape_json(&info.path),
1418                r.libtorch.valid_dir
1419            );
1420            if let Some(v) = &info.torch_version {
1421                let _ = write!(b, ",\"torch\":\"{}\"", system::escape_json(v));
1422            }
1423            if let Some(c) = &info.cuda_version {
1424                let _ = write!(b, ",\"cuda\":\"{}\"", system::escape_json(c));
1425            }
1426            if let Some(a) = &info.archs {
1427                let _ = write!(b, ",\"archs\":\"{}\"", system::escape_json(a));
1428            }
1429            b.push_str(",\"archs_match\":[");
1430            for (i, (gpu, ok)) in r.libtorch.archs_match.iter().enumerate() {
1431                if i > 0 {
1432                    b.push(',');
1433                }
1434                let _ = write!(b, "{{\"gpu\":{},\"covered\":{}}}", gpu, ok);
1435            }
1436            b.push(']');
1437            b.push('}');
1438        }
1439        None => b.push_str("null"),
1440    }
1441
1442    // Shared data path
1443    b.push_str(",\"data_path\":");
1444    if r.data_path.skipped {
1445        b.push_str("null");
1446    } else {
1447        let _ = write!(
1448            b,
1449            "{{\"path\":\"{}\",\"exists\":{},\"readable\":{}",
1450            system::escape_json(&r.data_path.path.display().to_string()),
1451            r.data_path.exists,
1452            r.data_path.readable
1453        );
1454        if let Some(t) = &r.data_path.fs_type {
1455            let _ = write!(b, ",\"fs_type\":\"{}\"", system::escape_json(t));
1456        }
1457        b.push('}');
1458    }
1459
1460    // NCCL — always emit an object now (even when host scan was
1461    // skipped via Docker), so consumers can read `via_docker` without
1462    // null-checking.
1463    b.push_str(",\"nccl\":");
1464    if r.nccl.library_path.is_none() && r.nccl.via_docker.is_none() {
1465        b.push_str("null");
1466    } else {
1467        b.push('{');
1468        let mut first = true;
1469        if let Some(p) = &r.nccl.library_path {
1470            let _ = write!(
1471                b,
1472                "\"library_path\":\"{}\",\"count\":{}",
1473                system::escape_json(&p.display().to_string()),
1474                r.nccl.all_found.len()
1475            );
1476            first = false;
1477        }
1478        if let Some(svc) = &r.nccl.via_docker {
1479            if !first {
1480                b.push(',');
1481            }
1482            let _ = write!(b, "\"via_docker\":\"{}\"", system::escape_json(svc));
1483        }
1484        b.push('}');
1485    }
1486
1487    // Issues (errors) + warnings + verdict.
1488    b.push_str(",\"issues\":[");
1489    for (i, msg) in r.issues.iter().enumerate() {
1490        if i > 0 {
1491            b.push(',');
1492        }
1493        let _ = write!(b, "\"{}\"", system::escape_json(msg));
1494    }
1495    b.push(']');
1496    b.push_str(",\"warnings\":[");
1497    for (i, msg) in r.warnings.iter().enumerate() {
1498        if i > 0 {
1499            b.push(',');
1500        }
1501        let _ = write!(b, "\"{}\"", system::escape_json(msg));
1502    }
1503    b.push(']');
1504    let _ = write!(b, ",\"ready\":{}", r.green());
1505    b.push('}');
1506    b
1507}
1508
1509// ---------------------------------------------------------------------------
1510// Tests
1511// ---------------------------------------------------------------------------
1512
1513#[cfg(test)]
1514mod tests {
1515    use super::*;
1516
1517    // --- GPU toolkit headers -------------------------------------------
1518
1519    #[test]
1520    fn toolkit_warning_names_every_missing_header_and_its_package() {
1521        // The REAL requirements table, not a hand-picked subset: probe
1522        // reporting clean while the build fails on the eighth header is
1523        // exactly the drift this check exists to prevent. (Assumes the
1524        // test host has no /usr/include/hip — true of the dev and cuda
1525        // containers.)
1526        let root = PathBuf::from("/nonexistent/flodl-probe-test/rocm");
1527        let w = gpu_toolkit_warning(
1528            "precompiled/rocm70",
1529            &root,
1530            "ROCM_PATH",
1531            crate::util::requirements::ROCM_HEADERS,
1532            None,
1533            "rocm",
1534        )
1535        .expect("absent toolkit must warn");
1536        for (header, _) in crate::util::requirements::ROCM_HEADERS {
1537            assert!(w.contains(header), "missing header {header}: {w}");
1538        }
1539        assert!(w.contains("precompiled/rocm70"), "{w}");
1540        assert!(w.contains("ROCM_PATH"), "{w}");
1541        // The install line is whatever THIS platform's is: apt names,
1542        // dnf names, brew with a caveat, or a WSL2 pointer that names no
1543        // package at all. Asserting one family's spelling is how a green
1544        // ubuntu run shipped a warning that failed on rocky, macOS and
1545        // windows at once; the spellings themselves are pinned where they
1546        // are decided, in `requirements::install_hint`.
1547        let packages = crate::util::requirements::packages_for(
1548            &crate::util::requirements::ROCM_HEADERS
1549                .iter()
1550                .collect::<Vec<_>>(),
1551        );
1552        let hint = crate::util::requirements::install_hint(&packages);
1553        assert!(w.contains(&hint), "install line not `{hint}`: {w}");
1554    }
1555
1556    #[test]
1557    fn toolkit_warning_says_the_container_path_is_unaffected() {
1558        // Severity rationale, pinned: flodl's default workflow builds in
1559        // the dev container, where host headers are irrelevant. If this
1560        // sentence goes, the warning starts reading like a broken host.
1561        // The metapackage override is NVIDIA's line: cuda-toolkit covers
1562        // the set, where the per-header names carry version placeholders.
1563        let root = PathBuf::from("/nonexistent/flodl-probe-test/cuda");
1564        let w = gpu_toolkit_warning(
1565            "precompiled/cu128",
1566            &root,
1567            "CUDA_HOME",
1568            &[("cuda_runtime.h", "cuda-cudart-dev-<M>-<m>")],
1569            Some("cuda-toolkit libnccl-dev"),
1570            "cuda",
1571        )
1572        .unwrap();
1573        assert!(w.contains("dev container is unaffected"), "{w}");
1574        assert!(w.contains("--features cuda"), "{w}");
1575        let hint = crate::util::requirements::install_hint(&[
1576            "cuda-toolkit".to_string(),
1577            "libnccl-dev".to_string(),
1578        ]);
1579        assert!(w.contains(&hint), "metapackage line not `{hint}`: {w}");
1580        assert!(
1581            !w.contains("<M>-<m>"),
1582            "placeholders must not reach the user: {w}"
1583        );
1584    }
1585
1586    #[test]
1587    fn toolkit_present_warns_nothing_and_partial_reports_only_the_gap() {
1588        // A real include/ layout, because the requirements checker looks
1589        // under <root>/include (and the system dirs) exactly as the
1590        // compiler will.
1591        let root = std::env::temp_dir().join(format!("fdl-probe-toolkit-{}", std::process::id()));
1592        std::fs::create_dir_all(root.join("include/hip")).unwrap();
1593        std::fs::write(root.join("include/hip/hip_runtime.h"), "//").unwrap();
1594
1595        assert!(
1596            gpu_toolkit_warning(
1597                "precompiled/rocm70",
1598                &root,
1599                "ROCM_PATH",
1600                &[("hip/hip_runtime.h", "hip-dev")],
1601                None,
1602                "rocm",
1603            )
1604            .is_none(),
1605            "a present header must not warn"
1606        );
1607        let w = gpu_toolkit_warning(
1608            "precompiled/rocm70",
1609            &root,
1610            "ROCM_PATH",
1611            &[
1612                ("hip/hip_runtime.h", "hip-dev"),
1613                ("rccl/rccl.h", "rccl-dev"),
1614            ],
1615            None,
1616            "rocm",
1617        )
1618        .expect("one missing header is still a warning");
1619        assert!(w.contains("rccl/rccl.h"), "{w}");
1620        assert!(
1621            !w.contains("hip_runtime"),
1622            "must not list the header it found: {w}"
1623        );
1624        assert!(!w.contains("hip-dev"), "nor the package it owns: {w}");
1625        let _ = std::fs::remove_dir_all(&root);
1626    }
1627
1628    #[test]
1629    fn cpu_variant_wants_no_toolkit() {
1630        // `variant_vendor` returns None for a CPU build, which is the
1631        // gate that keeps this whole check silent on CPU-only hosts.
1632        assert!(detect::variant_vendor("precompiled/cpu").is_none());
1633        assert!(detect::variant_vendor("precompiled/cpu-linux-aarch64").is_none());
1634        // And the vendors that DO imply a toolkit still resolve.
1635        assert_eq!(
1636            detect::variant_vendor("precompiled/rocm70"),
1637            Some(GpuVendor::Amd)
1638        );
1639        assert_eq!(
1640            detect::variant_vendor("precompiled/cu128"),
1641            Some(GpuVendor::Nvidia)
1642        );
1643    }
1644
1645    #[test]
1646    fn data_path_check_skipped_when_flag_set() {
1647        let mut issues = Vec::new();
1648        let mut warnings = Vec::new();
1649        let status = check_data_path(
1650            PathBuf::from("/nonexistent"),
1651            true,
1652            false,
1653            &mut issues,
1654            &mut warnings,
1655        );
1656        assert!(status.skipped);
1657        assert!(
1658            issues.is_empty(),
1659            "skip_mount must suppress missing-path issue"
1660        );
1661        assert!(
1662            warnings.is_empty(),
1663            "skip_mount must suppress missing-path warning"
1664        );
1665    }
1666
1667    #[test]
1668    fn data_path_check_explicit_missing_is_error() {
1669        let mut issues = Vec::new();
1670        let mut warnings = Vec::new();
1671        let status = check_data_path(
1672            PathBuf::from("/this/should/never/exist/flodl-probe-test"),
1673            false,
1674            true, // explicit
1675            &mut issues,
1676            &mut warnings,
1677        );
1678        assert!(!status.exists);
1679        assert!(!status.readable);
1680        assert_eq!(issues.len(), 1, "explicit missing path → error");
1681        assert!(warnings.is_empty());
1682    }
1683
1684    #[test]
1685    fn data_path_check_default_missing_is_warning() {
1686        let mut issues = Vec::new();
1687        let mut warnings = Vec::new();
1688        let status = check_data_path(
1689            PathBuf::from("/this/should/never/exist/flodl-probe-test"),
1690            false,
1691            false, // convention default — not explicit
1692            &mut issues,
1693            &mut warnings,
1694        );
1695        assert!(!status.exists);
1696        assert!(issues.is_empty(), "default missing path must NOT error");
1697        assert_eq!(warnings.len(), 1, "default missing path → warning");
1698    }
1699
1700    #[test]
1701    fn data_path_check_reports_readable_tmp() {
1702        let mut issues = Vec::new();
1703        let mut warnings = Vec::new();
1704        // `env::temp_dir()`, not a literal "/tmp": the assertion is that a
1705        // path which exists is *reported* as existing, and hardcoding a
1706        // POSIX path made this fail on Windows for a reason that had
1707        // nothing to do with check_data_path (which was right to call a
1708        // missing path missing).
1709        let status = check_data_path(
1710            std::env::temp_dir(),
1711            false,
1712            false,
1713            &mut issues,
1714            &mut warnings,
1715        );
1716        // The temp dir is readable on any host that can run this test; if
1717        // not we'd see it in `issues` and the test would surface the
1718        // surprise.
1719        assert!(status.exists);
1720        assert!(status.readable);
1721        assert!(issues.is_empty(), "issues = {:?}", issues);
1722        assert!(warnings.is_empty(), "warnings = {:?}", warnings);
1723    }
1724
1725    #[test]
1726    fn nccl_via_docker_skips_host_scan() {
1727        let mut issues = Vec::new();
1728        let status = check_nccl(Some("cuda".into()), &mut issues);
1729        assert!(
1730            issues.is_empty(),
1731            "docker-served NCCL must not produce errors"
1732        );
1733        assert!(status.library_path.is_none());
1734        assert!(status.all_found.is_empty());
1735        assert_eq!(status.via_docker.as_deref(), Some("cuda"));
1736    }
1737
1738    #[test]
1739    fn verdict_format_three_tier() {
1740        // No errors, no warnings → READY.
1741        let r0 = ProbeReport {
1742            host: "h".into(),
1743            gpus: vec![],
1744            libtorch: LibtorchStatus {
1745                info: None,
1746                valid_dir: false,
1747                archs_match: vec![],
1748            },
1749            data_path: DataPathStatus {
1750                path: PathBuf::new(),
1751                exists: false,
1752                readable: false,
1753                fs_type: None,
1754                skipped: true,
1755            },
1756            nccl: NcclStatus {
1757                library_path: None,
1758                all_found: vec![],
1759                via_docker: None,
1760            },
1761            issues: vec![],
1762            warnings: vec![],
1763        };
1764        assert!(r0.green());
1765
1766        // Warning-only is still green (exit 0).
1767        let r1 = ProbeReport {
1768            warnings: vec!["w".into()],
1769            ..clone_report(&r0)
1770        };
1771        assert!(r1.green());
1772
1773        // Error flips green to false.
1774        let r2 = ProbeReport {
1775            issues: vec!["e".into()],
1776            ..clone_report(&r0)
1777        };
1778        assert!(!r2.green());
1779    }
1780
1781    // Local clone helper — ProbeReport intentionally not Clone (Vec<GpuInfo>
1782    // has its own ownership).
1783    fn clone_report(r: &ProbeReport) -> ProbeReport {
1784        ProbeReport {
1785            host: r.host.clone(),
1786            gpus: vec![],
1787            libtorch: LibtorchStatus {
1788                info: None,
1789                valid_dir: r.libtorch.valid_dir,
1790                archs_match: vec![],
1791            },
1792            data_path: DataPathStatus {
1793                path: r.data_path.path.clone(),
1794                exists: r.data_path.exists,
1795                readable: r.data_path.readable,
1796                fs_type: r.data_path.fs_type.clone(),
1797                skipped: r.data_path.skipped,
1798            },
1799            nccl: NcclStatus {
1800                library_path: r.nccl.library_path.clone(),
1801                all_found: r.nccl.all_found.clone(),
1802                via_docker: r.nccl.via_docker.clone(),
1803            },
1804            issues: r.issues.clone(),
1805            warnings: r.warnings.clone(),
1806        }
1807    }
1808
1809    #[test]
1810    fn json_emits_warnings_array() {
1811        let r = ProbeReport {
1812            host: "h".into(),
1813            gpus: vec![],
1814            libtorch: LibtorchStatus {
1815                info: None,
1816                valid_dir: false,
1817                archs_match: vec![],
1818            },
1819            data_path: DataPathStatus {
1820                path: PathBuf::new(),
1821                exists: false,
1822                readable: false,
1823                fs_type: None,
1824                skipped: true,
1825            },
1826            nccl: NcclStatus {
1827                library_path: None,
1828                all_found: vec![],
1829                via_docker: Some("cuda".into()),
1830            },
1831            issues: vec![],
1832            warnings: vec!["data-path missing".into()],
1833        };
1834        let j = report_to_json_object(&r);
1835        let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
1836        assert!(v["ready"].as_bool().unwrap());
1837        let warns = v["warnings"].as_array().expect("warnings: []");
1838        assert_eq!(warns.len(), 1);
1839        assert_eq!(v["nccl"]["via_docker"].as_str(), Some("cuda"));
1840    }
1841
1842    #[test]
1843    fn json_survives_control_chars_in_names_and_paths() {
1844        // A tab / CR in a GPU name or mount path previously produced
1845        // invalid JSON that broke cluster probe fan-in.
1846        let r = ProbeReport {
1847            host: "h\tost".into(),
1848            gpus: vec![GpuInfo {
1849                index: 0,
1850                vendor: GpuVendor::Nvidia,
1851                name: "Weird\tGPU \"X\"\r\n".into(),
1852                arch: GpuArch::Sm { major: 8, minor: 6 },
1853                total_memory_mb: 1024,
1854            }],
1855            libtorch: LibtorchStatus {
1856                info: None,
1857                valid_dir: false,
1858                archs_match: vec![],
1859            },
1860            data_path: DataPathStatus {
1861                path: PathBuf::from("/mnt/na\ts"),
1862                exists: true,
1863                readable: true,
1864                fs_type: Some("virtio\u{1}fs".into()),
1865                skipped: false,
1866            },
1867            nccl: NcclStatus {
1868                library_path: None,
1869                all_found: vec![],
1870                via_docker: None,
1871            },
1872            issues: vec!["line1\nline2\ttabbed".into()],
1873            warnings: vec![],
1874        };
1875        let j = report_to_json_object(&r);
1876        let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
1877        assert_eq!(v["gpus"][0]["name"].as_str(), Some("Weird\tGPU \"X\"\r\n"));
1878        assert_eq!(v["data_path"]["fs_type"].as_str(), Some("virtio\u{1}fs"));
1879        assert_eq!(v["issues"][0].as_str(), Some("line1\nline2\ttabbed"));
1880    }
1881
1882    #[test]
1883    fn parse_remote_json_flags_schema_skew() {
1884        // A remote fdl speaking a different probe schema must surface as
1885        // version skew, not parse as a healthy zero-GPU host.
1886        let worker: ClusterWorker = serde_yaml_ng::from_str(
1887            "host: pascal\nlocal_devices: [0]\nnccl_socket_ifname: lo\npath: /opt/flodl",
1888        )
1889        .expect("minimal worker");
1890        let report =
1891            parse_remote_json(r#"{"something":"else"}"#, &worker).expect("valid JSON parses");
1892        assert!(
1893            report.issues.iter().any(|i| i.contains("version skew")),
1894            "issues: {:?}",
1895            report.issues
1896        );
1897    }
1898
1899    /// Minimal worker fixture for the wire tests below.
1900    fn wire_test_worker() -> ClusterWorker {
1901        serde_yaml_ng::from_str(
1902            "host: pascal\nlocal_devices: [0]\nnccl_socket_ifname: lo\npath: /opt/flodl",
1903        )
1904        .expect("minimal worker")
1905    }
1906
1907    #[test]
1908    fn gpu_wire_round_trips_both_vendors() {
1909        // The probe JSON is a real wire: `fdl @cluster probe` SSHes and
1910        // parses what the remote `fdl probe --json` emitted. Emit and
1911        // parse must therefore agree for every vendor, or a remote AMD
1912        // host reads back as something else.
1913        let r = ProbeReport {
1914            host: "h".into(),
1915            gpus: vec![
1916                GpuInfo {
1917                    index: 0,
1918                    vendor: GpuVendor::Nvidia,
1919                    name: "NVIDIA GeForce RTX 5060 Ti".into(),
1920                    arch: GpuArch::Sm {
1921                        major: 12,
1922                        minor: 0,
1923                    },
1924                    total_memory_mb: 16311,
1925                },
1926                GpuInfo {
1927                    index: 1,
1928                    vendor: GpuVendor::Amd,
1929                    name: "AMD Radeon RX 6800".into(),
1930                    arch: GpuArch::Gfx("gfx1030".into()),
1931                    total_memory_mb: 16384,
1932                },
1933            ],
1934            libtorch: LibtorchStatus {
1935                info: None,
1936                valid_dir: false,
1937                archs_match: vec![],
1938            },
1939            data_path: DataPathStatus {
1940                path: PathBuf::from("/d"),
1941                exists: true,
1942                readable: true,
1943                fs_type: None,
1944                skipped: false,
1945            },
1946            nccl: NcclStatus {
1947                library_path: None,
1948                all_found: vec![],
1949                via_docker: None,
1950            },
1951            issues: vec![],
1952            warnings: vec![],
1953        };
1954        let back = parse_remote_json(&report_to_json_object(&r), &wire_test_worker())
1955            .expect("emitted JSON parses");
1956        assert_eq!(back.gpus.len(), 2, "warnings: {:?}", back.warnings);
1957        assert_eq!(
1958            back.gpus[0].arch,
1959            GpuArch::Sm {
1960                major: 12,
1961                minor: 0
1962            }
1963        );
1964        assert_eq!(back.gpus[0].vendor, GpuVendor::Nvidia);
1965        assert_eq!(back.gpus[1].arch, GpuArch::Gfx("gfx1030".into()));
1966        assert_eq!(back.gpus[1].vendor, GpuVendor::Amd);
1967        assert_eq!(back.gpus[1].total_memory_mb, 16384);
1968    }
1969
1970    #[test]
1971    fn gpu_wire_reads_a_legacy_sm_only_remote() {
1972        // An older `fdl` on the remote emits `sm` and no `vendor`/`arch`.
1973        // It only ever ran on NVIDIA, so that is the right assumption.
1974        let json =
1975            r#"{"host":"p","gpus":[{"index":0,"name":"A100","sm":"sm_80","vram_mb":81920}]}"#;
1976        let back = parse_remote_json(json, &wire_test_worker()).expect("parses");
1977        assert_eq!(back.gpus.len(), 1);
1978        assert_eq!(back.gpus[0].vendor, GpuVendor::Nvidia);
1979        assert_eq!(back.gpus[0].arch, GpuArch::Sm { major: 8, minor: 0 });
1980    }
1981
1982    #[test]
1983    fn gpu_wire_warns_rather_than_inventing_an_arch() {
1984        // An unrecognized arch must not fall through to a default: a
1985        // bogus arch compares as incompatible with every libtorch
1986        // variant, which reads as a hardware problem the user does not
1987        // have.
1988        let json = r#"{"host":"p","gpus":[{"index":0,"name":"X","vendor":"amd","arch":"wat","vram_mb":8}]}"#;
1989        let back = parse_remote_json(json, &wire_test_worker()).expect("parses");
1990        assert!(back.gpus.is_empty());
1991        assert!(
1992            back.warnings.iter().any(|w| w.contains("unrecognized")),
1993            "warnings: {:?}",
1994            back.warnings
1995        );
1996    }
1997
1998    #[test]
1999    fn fs_type_detected_for_root() {
2000        let t = detect_fs_type(Path::new("/"));
2001        // / is mounted on every Linux box; detection should not fail.
2002        // Skip on non-Linux (CI matrix) — /proc/mounts unavailable.
2003        if std::path::Path::new("/proc/mounts").exists() {
2004            assert!(t.is_some(), "expected fs_type for /");
2005        }
2006    }
2007
2008    #[test]
2009    fn mounted_at_answers_only_for_a_real_mount_point() {
2010        if !std::path::Path::new("/proc/mounts").exists() {
2011            return;
2012        }
2013        // `/` is a mount point on every Linux box.
2014        assert!(mounted_at(Path::new("/")).is_some());
2015        // A path INSIDE a mount is not the mount point — this is the
2016        // whole distinction from `detect_fs_type`, and the one that
2017        // decides "mount it" from "already mounted".
2018        let inside = std::env::temp_dir().join("fdl-not-a-mount-point");
2019        assert!(mounted_at(&inside).is_none());
2020        assert!(detect_fs_type(&inside).is_some(), "but it has an fs type");
2021    }
2022
2023    #[test]
2024    fn mount_fields_come_back_unescaped() {
2025        assert_eq!(unescape_mount("exa:/flodl\\040data"), "exa:/flodl data");
2026        assert_eq!(unescape_mount("plain:/flodl/data"), "plain:/flodl/data");
2027        // A trailing backslash, or one that is not a full octal escape,
2028        // is passed through rather than eating the rest of the field.
2029        assert_eq!(unescape_mount("odd\\"), "odd\\");
2030        assert_eq!(unescape_mount("odd\\9x"), "odd\\9x");
2031    }
2032}