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::system::{self, GpuInfo};
33
34// ---------------------------------------------------------------------------
35// Public entry
36// ---------------------------------------------------------------------------
37
38/// Run the probe.
39///
40/// **Single-host** (no active env overlay): probes the local box and
41/// emits one report. `--data-path` overrides config; `--skip-mount`
42/// short-circuits the shared-data check.
43///
44/// **Cluster** (`fdl @cluster probe` / `FDL_ENV=cluster`): loads
45/// `fdl.<env>.yml`'s `cluster.workers:` list. For each host: if it's
46/// the local host, probes in-process; otherwise SSHes to it and runs
47/// `<worker.path>/target/release/fdl probe --json` remotely. Per-host
48/// JSON is parsed back into [`ProbeReport`] and aggregated.
49///
50/// Exit code: `0` when every probed host is green; `1` when any
51/// host raised issues.
52pub fn run(
53    json: bool,
54    skip_mount: bool,
55    data_path_override: Option<PathBuf>,
56    libtorch_path_override: Option<PathBuf>,
57    via_docker: Option<String>,
58) -> i32 {
59    let ctx = Context::resolve();
60    // Cluster fan-out only applies when no explicit libtorch override
61    // is passed — overrides are how the *remote* probe is invoked, so
62    // we must NOT recurse back into cluster mode on the remote side.
63    if libtorch_path_override.is_none() {
64        if let Ok(env_name) = std::env::var("FDL_ENV") {
65            if let Some(cluster) = load_cluster_for_env(&ctx, &env_name) {
66                return run_cluster(&cluster, json, skip_mount);
67            }
68        }
69    }
70    // Single-host (local OR remote-being-probed). When `--data-path` is
71    // passed explicitly, treat a missing path as an error; when absent
72    // (falling back to DEFAULT_DATA_PATH), treat it as a warning.
73    let data_path_explicit = data_path_override.is_some();
74    let report = probe_local(
75        &ctx,
76        skip_mount,
77        data_path_override,
78        libtorch_path_override,
79        via_docker,
80        data_path_explicit,
81    );
82    if json {
83        print_json(&report);
84    } else {
85        print_report(&report);
86    }
87    if report.green() { 0 } else { 1 }
88}
89
90fn load_cluster_for_env(ctx: &Context, env_name: &str) -> Option<config::ClusterConfig> {
91    let config_path = config::find_config(&ctx.root)?;
92    let project = config::load_project_with_env(&config_path, Some(env_name)).ok()?;
93    project.cluster
94}
95
96// ---------------------------------------------------------------------------
97// Cluster fan-out
98// ---------------------------------------------------------------------------
99
100fn run_cluster(cluster: &config::ClusterConfig, json: bool, skip_mount: bool) -> i32 {
101    let local = resolve_local_hostname();
102    let mut reports: Vec<ProbeReport> = Vec::with_capacity(cluster.workers.len());
103    for worker in &cluster.workers {
104        let r = if worker.host == local {
105            // Local rank: probe in-process, honor the host's data_path,
106            // arch (libtorch variant), and docker service (if set in cluster.yml).
107            // Matches the remote-probe path so the local rank's report
108            // shape is identical to the SSH-probed remotes. Only pass an
109            // explicit data_path_override when the host declared one;
110            // omitting it preserves the "default = warning, not error"
111            // semantics in [`check_data_path`].
112            let ctx = Context::resolve();
113            let data_path_explicit = worker.data_path.is_some();
114            probe_local(
115                &ctx,
116                skip_mount,
117                worker.data_path.as_ref().map(PathBuf::from),
118                // Convention: libtorch lives at `<worker.path>/libtorch/<worker.arch>`
119                // when the host declares an arch; else probe walks
120                // `<worker.path>/libtorch/.active` (single-host default).
121                worker.arch
122                    .as_ref()
123                    .map(|a| PathBuf::from(&worker.path).join("libtorch").join(a)),
124                worker.docker.clone(),
125                data_path_explicit,
126            )
127        } else {
128            probe_remote_via_ssh(worker, skip_mount)
129        };
130        reports.push(r);
131    }
132    let any_red = reports.iter().any(|r| !r.green());
133    if json {
134        print_cluster_json(&reports);
135    } else {
136        print_cluster_report(&reports);
137    }
138    if any_red { 1 } else { 0 }
139}
140
141/// SSH to `host` and run `fdl probe --json` there. The remote `fdl`
142/// is invoked bare and resolved by the remote shell's PATH (each host
143/// owns its own `fdl` install; the controller does not reach into the
144/// remote's build tree). Returns a synthetic `ProbeReport` carrying any
145/// SSH/parse failure in `issues` when the remote call fails — caller
146/// treats those as red verdicts.
147fn probe_remote_via_ssh(worker: &ClusterWorker, skip_mount: bool) -> ProbeReport {
148    let ssh_target = worker
149        .ssh
150        .as_ref()
151        .and_then(|s| s.target.as_deref())
152        .unwrap_or(&worker.host)
153        .to_string();
154    // Invoke bare `fdl` and rely on the remote shell's PATH. Each
155    // host owns its fdl install (typically `cargo install flodl-cli`
156    // into ~/.cargo/bin or ~/.local/bin); the controller does not
157    // reach into the remote's build tree. If a host lacks `fdl` on
158    // PATH the SSH command returns "fdl: command not found" exit
159    // 127, which the probe-result parser surfaces as an SSH error
160    // for that host.
161    let mut remote_args: Vec<String> = vec![
162        "fdl".into(),
163        "probe".into(),
164        "--json".into(),
165    ];
166    // Only forward --data-path when the host declared one. Without it,
167    // the remote falls back to DEFAULT_DATA_PATH and the probe treats a
168    // missing path as a WARNING (convention default) rather than an
169    // ERROR (explicit promise the user made in cluster.yml).
170    if let Some(dp) = &worker.data_path {
171        remote_args.push("--data-path".into());
172        remote_args.push(dp.clone());
173    }
174    if skip_mount {
175        remote_args.push("--skip-mount".into());
176    }
177    // Pass the host's libtorch path to the remote probe so the worker
178    // doesn't have to discover libtorch from its filesystem. Derived
179    // from the convention `<worker.path>/libtorch/<worker.arch>` when
180    // arch is declared; otherwise omitted, and the remote probe walks
181    // `<worker.path>/libtorch/.active` (single-host default).
182    if let Some(arch) = &worker.arch {
183        remote_args.push("--libtorch-path".into());
184        remote_args.push(format!(
185            "{path}/libtorch/{arch}",
186            path = worker.path.trim_end_matches('/'),
187        ));
188    }
189    // Pass the host's docker: compose service. Tells the remote probe
190    // that NCCL ships inside the container image, so it should report
191    // "via Docker image <svc>" instead of scanning host library paths.
192    if let Some(svc) = &worker.docker {
193        remote_args.push("--docker".into());
194        remote_args.push(svc.clone());
195    }
196    // Quote each remote arg into a single shell-safe command string
197    // (paths and options may contain spaces / metacharacters).
198    let quoted = remote_args
199        .iter()
200        .map(|a| crate::util::shell::posix_quote(a))
201        .collect::<Vec<_>>()
202        .join(" ");
203    // cd into the remote host's project path BEFORE invoking fdl so
204    // `Context::resolve()` walks up from there + finds the shared
205    // libtorch/.active. Without the cd, fdl walks from the SSH login
206    // dir (typically ~) and either misses the project root or
207    // resolves a stale local fdl install.
208    let remote_cmd = format!(
209        "cd {} && {quoted}",
210        crate::util::shell::posix_quote(&worker.path),
211    );
212
213    // Honor the worker's `ssh:` sub-block (port / user / identity_file /
214    // options) just like the cluster dispatch path — otherwise a
215    // Docker-container rank on `127.0.0.1:2222` with an identity_file is
216    // dialed on the default port 22 and the connect is refused (the
217    // probe then reports the host red even though dispatch works fine).
218    let mut cmd = Command::new("ssh");
219    // User ssh.options first (they win), then flodl's defaults (M17).
220    crate::cluster::apply_worker_ssh_opts(&mut cmd, worker);
221    cmd.args([
222        "-T",
223        "-o",
224        "BatchMode=yes",
225        "-o",
226        "ServerAliveInterval=10",
227        "-o",
228        "ServerAliveCountMax=3",
229    ]);
230    cmd.arg(&ssh_target).arg(&remote_cmd);
231    let output = cmd.output();
232
233    let mut report = ProbeReport {
234        host: worker.host.clone(),
235        gpus: Vec::new(),
236        libtorch: LibtorchStatus {
237            info: None,
238            valid_dir: false,
239            archs_match: Vec::new(),
240        },
241        data_path: DataPathStatus {
242            path: PathBuf::from(worker.effective_data_path()),
243            exists: false,
244            readable: false,
245            fs_type: None,
246            skipped: skip_mount,
247        },
248        nccl: NcclStatus {
249            library_path: None,
250            all_found: Vec::new(),
251            via_docker: worker.docker.clone(),
252        },
253        issues: Vec::new(),
254        warnings: Vec::new(),
255    };
256    match output {
257        Err(e) => {
258            report.issues.push(format!(
259                "ssh to `{ssh_target}` failed before probe ran: {e}"
260            ));
261        }
262        Ok(out) => {
263            // The remote probe returns exit 1 when it found issues —
264            // that's the SAME signal the remote report carries via
265            // its own `issues` field. Don't treat it as fatal here;
266            // try to parse stdout regardless. Only fall back to a
267            // synthetic SSH-error report when parse actually fails.
268            let stdout = String::from_utf8_lossy(&out.stdout);
269            match parse_remote_json(&stdout, worker) {
270                Ok(r) => report = r,
271                Err(parse_err) => {
272                    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
273                    report.issues.push(format!(
274                        "remote probe on `{ssh_target}` exited {} — \
275                         stdout did not parse as JSON ({parse_err}); \
276                         stderr: {stderr}; first 200 chars of stdout: {:?}",
277                        out.status,
278                        stdout.chars().take(200).collect::<String>(),
279                    ));
280                }
281            }
282        }
283    }
284    report
285}
286
287/// Parse the remote `fdl probe --json` output back into a
288/// [`ProbeReport`]. Minimal parser — pulls the fields the report
289/// formatter needs and trusts the remote produced what it produces.
290/// `host` is the cluster.yml entry; used to fill the `host` field of
291/// the report so name matches the topology (the remote returns its
292/// `hostname(1)`, which may differ from the cluster.yml name and is
293/// the more common source of "probe says host X but cluster.yml says
294/// host Y" diagnostics).
295fn parse_remote_json(json: &str, worker: &ClusterWorker) -> Result<ProbeReport, String> {
296    let v: serde_json::Value = serde_json::from_str(json.trim())
297        .map_err(|e| format!("JSON parse: {e}"))?;
298
299    let mut report = ProbeReport {
300        host: worker.host.clone(),
301        gpus: Vec::new(),
302        libtorch: LibtorchStatus {
303            info: None,
304            valid_dir: false,
305            archs_match: Vec::new(),
306        },
307        data_path: DataPathStatus {
308            path: PathBuf::from(worker.effective_data_path()),
309            exists: false,
310            readable: false,
311            fs_type: None,
312            skipped: false,
313        },
314        nccl: NcclStatus {
315            library_path: None,
316            all_found: Vec::new(),
317            via_docker: worker.docker.clone(),
318        },
319        issues: Vec::new(),
320        warnings: Vec::new(),
321    };
322
323    if let Some(gpus) = v.get("gpus").and_then(|g| g.as_array()) {
324        for g in gpus {
325            let index = g.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
326            let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
327            let sm = g.get("sm").and_then(|v| v.as_str()).unwrap_or("sm_0");
328            let (sm_major, sm_minor) = parse_sm(sm);
329            let total_memory_mb = g.get("vram_mb").and_then(|v| v.as_u64()).unwrap_or(0);
330            report.gpus.push(GpuInfo {
331                index,
332                name,
333                sm_major,
334                sm_minor,
335                total_memory_mb,
336            });
337        }
338    }
339
340    if let Some(lt) = v.get("libtorch") {
341        if !lt.is_null() {
342            let path = lt.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
343            let valid_dir = lt.get("valid_dir").and_then(|v| v.as_bool()).unwrap_or(false);
344            let info = LibtorchInfo {
345                path,
346                torch_version: lt.get("torch").and_then(|v| v.as_str()).map(String::from),
347                cuda_version: lt.get("cuda").and_then(|v| v.as_str()).map(String::from),
348                archs: lt.get("archs").and_then(|v| v.as_str()).map(String::from),
349                source: None,
350            };
351            let mut archs_match = Vec::new();
352            if let Some(am) = lt.get("archs_match").and_then(|v| v.as_array()) {
353                for entry in am {
354                    let gpu = entry.get("gpu").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
355                    let covered = entry.get("covered").and_then(|v| v.as_bool()).unwrap_or(false);
356                    archs_match.push((gpu, covered));
357                }
358            }
359            report.libtorch = LibtorchStatus { info: Some(info), valid_dir, archs_match };
360        }
361    }
362
363    if let Some(dp) = v.get("data_path") {
364        if !dp.is_null() {
365            let path = dp
366                .get("path")
367                .and_then(|v| v.as_str())
368                .map(PathBuf::from)
369                .unwrap_or_else(|| PathBuf::from(worker.effective_data_path()));
370            let exists = dp.get("exists").and_then(|v| v.as_bool()).unwrap_or(false);
371            let readable = dp.get("readable").and_then(|v| v.as_bool()).unwrap_or(false);
372            let fs_type = dp.get("fs_type").and_then(|v| v.as_str()).map(String::from);
373            report.data_path = DataPathStatus {
374                path,
375                exists,
376                readable,
377                fs_type,
378                skipped: false,
379            };
380        } else {
381            report.data_path.skipped = true;
382        }
383    }
384
385    if let Some(nccl) = v.get("nccl") {
386        if !nccl.is_null() {
387            let p = nccl.get("library_path").and_then(|v| v.as_str()).map(PathBuf::from);
388            report.nccl.library_path = p.clone();
389            if let Some(p) = p {
390                report.nccl.all_found.push(p);
391            }
392            // Prefer the remote's reported via_docker over the
393            // cluster.yml field — the controller already passed it in
394            // via --docker so the remote echo confirms what was used;
395            // they should match, and using the remote's keeps the
396            // round-trip a single source of truth.
397            if let Some(svc) = nccl.get("via_docker").and_then(|v| v.as_str()) {
398                report.nccl.via_docker = Some(svc.to_string());
399            }
400        }
401    }
402
403    if let Some(issues) = v.get("issues").and_then(|v| v.as_array()) {
404        for i in issues {
405            if let Some(s) = i.as_str() {
406                report.issues.push(s.to_string());
407            }
408        }
409    }
410    if let Some(warnings) = v.get("warnings").and_then(|v| v.as_array()) {
411        for w in warnings {
412            if let Some(s) = w.as_str() {
413                report.warnings.push(s.to_string());
414            }
415        }
416    }
417
418    // Shape guard: the current emitter always writes these keys. Their
419    // complete absence means the remote fdl speaks a different probe
420    // schema (version skew) — surface that instead of letting the lenient
421    // per-field defaults masquerade as "no GPUs" / "not ready".
422    for key in ["gpus", "ready"] {
423        if v.get(key).is_none() {
424            report.issues.push(format!(
425                "remote probe JSON has no {key:?} field — the remote fdl \
426                 likely speaks a different probe schema (version skew); \
427                 update fdl on `{}`",
428                worker.host
429            ));
430        }
431    }
432
433    Ok(report)
434}
435
436fn parse_sm(s: &str) -> (u32, u32) {
437    // "sm_NNN" → (sm_major, sm_minor). sm_NN concatenates the two
438    // digits; reverse by treating last digit as minor when total
439    // string after "sm_" is 2 chars, else split major/minor on the
440    // canonical 2-digit minor convention NVIDIA uses (sm_120 = 12.0).
441    let n = s.trim_start_matches("sm_");
442    if let Ok(v) = n.parse::<u32>() {
443        // sm_120 -> 12.0; sm_61 -> 6.1; sm_90 -> 9.0; sm_86 -> 8.6.
444        // NVIDIA convention: last digit is minor.
445        let major = v / 10;
446        let minor = v % 10;
447        return (major, minor);
448    }
449    (0, 0)
450}
451
452// ---------------------------------------------------------------------------
453// Cluster output
454// ---------------------------------------------------------------------------
455
456fn print_cluster_report(reports: &[ProbeReport]) {
457    println!("floDl Cluster Probe — {} hosts", reports.len());
458    println!("{}", "=".repeat(40));
459    println!();
460    for (i, r) in reports.iter().enumerate() {
461        if i > 0 {
462            println!();
463            println!("{}", "-".repeat(40));
464            println!();
465        }
466        print_report(r);
467    }
468    println!();
469    let red = reports.iter().filter(|r| !r.green()).count();
470    let yellow = reports
471        .iter()
472        .filter(|r| r.green() && !r.warnings.is_empty())
473        .count();
474    let total = reports.len();
475    match (red, yellow) {
476        (0, 0) => println!("CLUSTER VERDICT: READY (all {total} hosts green)"),
477        (0, y) => println!("CLUSTER VERDICT: READY ({y}/{total} hosts have warnings)"),
478        (r, 0) => println!("CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors)"),
479        (r, y) => println!(
480            "CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors, \
481             {y} also have warnings)"
482        ),
483    }
484}
485
486fn print_cluster_json(reports: &[ProbeReport]) {
487    let mut b = String::with_capacity(4096);
488    b.push_str("{\"hosts\":[");
489    for (i, r) in reports.iter().enumerate() {
490        if i > 0 {
491            b.push(',');
492        }
493        b.push_str(&report_to_json_object(r));
494    }
495    b.push(']');
496    let red = reports.iter().filter(|r| !r.green()).count();
497    let _ = write!(b, ",\"hosts_total\":{}", reports.len());
498    let _ = write!(b, ",\"hosts_red\":{}", red);
499    let _ = write!(b, ",\"ready\":{}", red == 0);
500    b.push('}');
501    println!("{}", b);
502}
503
504// ---------------------------------------------------------------------------
505// Report structs
506// ---------------------------------------------------------------------------
507
508/// Top-level probe verdict for one host. `green()` is the aggregate
509/// gate.
510///
511/// `issues` are blocking errors (exit non-zero); `warnings` are advisory
512/// (exit zero, surfaced in the report). The split matters because
513/// "/flodl/data missing" on a single-host rig that doesn't use shared
514/// storage is informational, while a worker host that declared an
515/// explicit `data_path:` in cluster.yml and can't see it is broken.
516pub struct ProbeReport {
517    pub host: String,
518    pub gpus: Vec<GpuInfo>,
519    pub libtorch: LibtorchStatus,
520    pub data_path: DataPathStatus,
521    pub nccl: NcclStatus,
522    pub issues: Vec<String>,
523    pub warnings: Vec<String>,
524}
525
526impl ProbeReport {
527    /// `true` when no issues were collected — every checked component
528    /// passed (warnings do NOT flip this). Callers may still want to
529    /// inspect individual statuses for diagnostic detail; the exit
530    /// code follows this flag.
531    pub fn green(&self) -> bool {
532        self.issues.is_empty()
533    }
534}
535
536/// libtorch directory + arch metadata + per-GPU compatibility verdict.
537pub struct LibtorchStatus {
538    /// Parsed `.arch` metadata (if libtorch is present + readable).
539    pub info: Option<LibtorchInfo>,
540    /// `lib/` subdirectory present (cheap "is this a libtorch dir?"
541    /// check that doesn't require parsing).
542    pub valid_dir: bool,
543    /// Per-GPU `(gpu_index, archs_cover_this_gpu)`. Empty when libtorch
544    /// is missing.
545    pub archs_match: Vec<(u8, bool)>,
546}
547
548/// Shared-data path visibility + filesystem-type detection.
549pub struct DataPathStatus {
550    pub path: PathBuf,
551    pub exists: bool,
552    pub readable: bool,
553    /// Underlying filesystem type from `/proc/mounts` (e.g. `virtiofs`,
554    /// `nfs4`, `cifs`, `fuse.sshfs`, `ext4`). `None` when the path is
555    /// not mounted (falls inside the parent FS) or when /proc/mounts
556    /// is unavailable.
557    pub fs_type: Option<String>,
558    /// `true` when the check was explicitly bypassed via
559    /// `--skip-mount`; the path/exists fields are unset (`PathBuf::new`
560    /// + false) in that case.
561    pub skipped: bool,
562}
563
564/// NCCL discovery result. NCCL is loaded dynamically by libtorch, so
565/// the probe just hunts for `libnccl.so*` on the usual library paths
566/// — unless [`Self::via_docker`] is set, in which case NCCL ships
567/// inside the container image and the host scan is skipped.
568pub struct NcclStatus {
569    /// First `libnccl.so*` found, if any. Used in the report to show
570    /// the user which install will be picked up.
571    pub library_path: Option<PathBuf>,
572    /// All discovered `libnccl.so*` paths (informational; multiple
573    /// versions in different prefixes is a misconfiguration source).
574    pub all_found: Vec<PathBuf>,
575    /// Docker compose service that owns NCCL on this host. When set,
576    /// the probe records "via Docker image `<svc>`" instead of scanning
577    /// the host filesystem. `None` means the host runs flodl natively
578    /// and NCCL must live on it.
579    pub via_docker: Option<String>,
580}
581
582// ---------------------------------------------------------------------------
583// Single-host probe
584// ---------------------------------------------------------------------------
585
586/// Probe the local host. `data_path_override` (from `--data-path` CLI
587/// flag) overrides config; `skip_mount` short-circuits the shared-data
588/// check (useful for single-host setups without a shared FS
589/// configured); `libtorch_path_override` (from `--libtorch-path`)
590/// points at a libtorch install outside the project tree (used by
591/// cluster-mode remote probes where libtorch lives on a dedicated
592/// share like `/mnt/libtorch`). `via_docker` (from `--docker <svc>` or
593/// the cluster.yml host's `docker:` field) tells the probe NCCL ships
594/// inside a container image, so host-level NCCL scanning is replaced
595/// by an informational "via Docker image `<svc>`" line.
596///
597/// `data_path_explicit`: when `true`, a missing shared-data path is an
598/// ERROR (the user/cluster.yml promised it); when `false`, it's a
599/// WARNING (the convention default was used). Internal flag — callers
600/// must derive it from "did the caller pass an explicit data_path".
601pub fn probe_local(
602    ctx: &Context,
603    skip_mount: bool,
604    data_path_override: Option<PathBuf>,
605    libtorch_path_override: Option<PathBuf>,
606    via_docker: Option<String>,
607    data_path_explicit: bool,
608) -> ProbeReport {
609    let host = resolve_local_hostname();
610    let gpus = system::detect_gpus();
611    let mut issues: Vec<String> = Vec::new();
612    let mut warnings: Vec<String> = Vec::new();
613
614    let libtorch = match libtorch_path_override {
615        Some(p) => check_libtorch_at(&p, &gpus, &mut issues),
616        None => check_libtorch(&ctx.root, &gpus, &mut issues),
617    };
618    let data_path = check_data_path(
619        data_path_override.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_PATH)),
620        skip_mount,
621        data_path_explicit,
622        &mut issues,
623        &mut warnings,
624    );
625    let nccl = check_nccl(via_docker, &mut issues);
626
627    if gpus.is_empty() {
628        issues.push(
629            "no CUDA GPUs detected — nvidia-smi missing or driver \
630             unhealthy. Single-host CPU training will still work; \
631             multi-rank NCCL requires a working GPU stack."
632                .into(),
633        );
634    }
635
636    ProbeReport {
637        host,
638        gpus,
639        libtorch,
640        data_path,
641        nccl,
642        issues,
643        warnings,
644    }
645}
646
647/// Build a [`LibtorchStatus`] from a resolved [`LibtorchInfo`] (or
648/// `None` when the pointer could not be resolved). Used by the
649/// pointer-file shape of [`check_libtorch_at`]; mirrors the
650/// arch-check and valid-dir logic from [`check_libtorch`] without
651/// duplicating its `.active` walk.
652fn libtorch_status_from_info(
653    info: Option<LibtorchInfo>,
654    libtorch_root: &Path,
655    gpus: &[GpuInfo],
656    issues: &mut Vec<String>,
657) -> LibtorchStatus {
658    let valid_dir = match &info {
659        Some(i) => libtorch_root.join(&i.path).join("lib").is_dir(),
660        None => false,
661    };
662    let archs_match = match &info {
663        Some(i) => detect::arch_coverage(i, gpus, issues),
664        None => {
665            issues.push(
666                "libtorch pointer file did not resolve to a configured \
667                 variant (file empty or missing). Check the `.active*` \
668                 content names a real subdir under `libtorch/`."
669                    .into(),
670            );
671            Vec::new()
672        }
673    };
674    LibtorchStatus { info, valid_dir, archs_match }
675}
676
677/// Variant that takes an explicit libtorch path instead of walking
678/// from the project root. Accepts three shapes:
679///
680/// 1. **Libtorch ROOT** (dir containing `.active` + `builds/` /
681///    `precompiled/`) — delegates to [`check_libtorch`] which walks
682///    `.active`.
683/// 2. **Pointer file** (file path ending in `.active*`, e.g.
684///    `libtorch/.active.blackwell`) — reads the pointer and resolves
685///    the variant relative to the file's parent directory. Used for
686///    heterogeneous rigs where each host's `cluster.yml` entry sets
687///    `arch:` to a different case-file subpath (e.g. `.active.blackwell`).
688/// 3. **Direct variant dir** (has `lib/libtorch.so` + optional
689///    `.arch`) — used as-is.
690fn check_libtorch_at(
691    path: &Path,
692    gpus: &[GpuInfo],
693    issues: &mut Vec<String>,
694) -> LibtorchStatus {
695    // Shape 2: a regular file whose name starts with `.active` is a
696    // pointer to a variant subdir. Resolve relative to the file's
697    // parent (the libtorch root). Note: `.active` itself is also a
698    // file but Shape 1 catches it via dir-containing-.active above.
699    if path.is_file()
700        && path.file_name()
701            .and_then(|n| n.to_str())
702            .is_some_and(|n| n.starts_with(".active"))
703    {
704        let libtorch_root = path.parent().unwrap_or(path);
705        let info = detect::read_active_from(path, libtorch_root);
706        return libtorch_status_from_info(info, libtorch_root, gpus, issues);
707    }
708    if path.join(".active").exists() {
709        return check_libtorch(path, gpus, issues);
710    }
711    let dir = path;
712    let valid_dir = dir.join("lib").is_dir();
713    if !valid_dir {
714        issues.push(format!(
715            "libtorch directory `{}` does not contain `lib/` — pass \
716             `--libtorch-path` pointing at a real libtorch install \
717             (the directory with `lib/libtorch.so`).",
718            dir.display()
719        ));
720        return LibtorchStatus {
721            info: None,
722            valid_dir: false,
723            archs_match: Vec::new(),
724        };
725    }
726    let info = detect::libtorch_info_from_dir(dir.display().to_string(), dir);
727    let archs_match = detect::arch_coverage(&info, gpus, issues);
728    LibtorchStatus { info: Some(info), valid_dir: true, archs_match }
729}
730
731fn check_libtorch(
732    root: &Path,
733    gpus: &[GpuInfo],
734    issues: &mut Vec<String>,
735) -> LibtorchStatus {
736    // `root` can be the project root OR the libtorch root (latter is
737    // what `--libtorch-path /path/to/libtorch` resolves to when the
738    // dir has `.active`). `read_active` expects the parent of
739    // `libtorch/`; if `root` is itself a libtorch root (has `.active`
740    // directly under it), reframe.
741    let info = if root.join(".active").exists() {
742        // Synthesize the parent + variant path, then call read_active
743        // with a synthetic parent that exposes `libtorch/.active`.
744        let active_text = std::fs::read_to_string(root.join(".active")).ok();
745        match active_text {
746            Some(t) => {
747                let variant = t.trim().to_string();
748                if variant.is_empty() {
749                    None
750                } else {
751                    let arch_dir = root.join(&variant);
752                    Some(detect::libtorch_info_from_dir(variant, &arch_dir))
753                }
754            }
755            None => None,
756        }
757    } else {
758        detect::read_active(root)
759    };
760    let valid_dir = match &info {
761        Some(i) => {
762            if root.join(".active").exists() {
763                root.join(&i.path).join("lib").is_dir()
764            } else {
765                detect::is_valid_variant(root, &i.path)
766            }
767        }
768        None => false,
769    };
770
771    let archs_match = match &info {
772        Some(i) => detect::arch_coverage(i, gpus, issues),
773        None => {
774            issues.push(
775                "libtorch not configured — `libtorch/.active` missing or \
776                 empty. Run `fdl libtorch download` or `fdl libtorch build` \
777                 to provision a variant."
778                    .into(),
779            );
780            Vec::new()
781        }
782    };
783
784    LibtorchStatus { info, valid_dir, archs_match }
785}
786
787fn check_data_path(
788    path: PathBuf,
789    skip_mount: bool,
790    explicit: bool,
791    issues: &mut Vec<String>,
792    warnings: &mut Vec<String>,
793) -> DataPathStatus {
794    if skip_mount {
795        return DataPathStatus {
796            path: PathBuf::new(),
797            exists: false,
798            readable: false,
799            fs_type: None,
800            skipped: true,
801        };
802    }
803    let exists = path.exists();
804    let readable = exists && std::fs::read_dir(&path).is_ok();
805    let fs_type = detect_fs_type(&path);
806
807    if !exists {
808        if explicit {
809            // The user (or cluster.yml) promised this path. Missing it
810            // is a launch-breaking error — training fan-out would
811            // discover this mid-run when a checkpoint write hangs.
812            issues.push(format!(
813                "shared data path `{}` does not exist on this host. flodl \
814                 assumes a shared filesystem (NAS / SMB / virtiofs / SSHFS) \
815                 mounted at the same logical path on every node. Mount the \
816                 shared storage or correct `data_path:` in cluster.yml.",
817                path.display()
818            ));
819        } else {
820            // No explicit path was declared — the convention default
821            // `/flodl/data` was tried. Missing it is fine for users who
822            // don't use shared storage; surface it as a warning so they
823            // know the default isn't wired up.
824            warnings.push(format!(
825                "convention shared-data path `{}` not present on this host \
826                 (no `data_path:` declared in cluster.yml). Ignore if you \
827                 don't use shared storage; otherwise set `data_path:` per \
828                 host or mount `{}`.",
829                path.display(),
830                path.display()
831            ));
832        }
833    } else if !readable {
834        issues.push(format!(
835            "shared data path `{}` exists but is not readable by the \
836             current user. Check mount permissions / uid mapping.",
837            path.display()
838        ));
839    }
840
841    DataPathStatus { path, exists, readable, fs_type, skipped: false }
842}
843
844fn check_nccl(via_docker: Option<String>, issues: &mut Vec<String>) -> NcclStatus {
845    // Docker-served host: NCCL lives inside the container image, not
846    // on the host. Skip the host scan entirely — scanning would
847    // false-positive on the false-error path that motivated the docker
848    // field (host shows "no libnccl.so" while training actually runs
849    // fine inside the cuda/dev image). Report as informational.
850    if via_docker.is_some() {
851        return NcclStatus {
852            library_path: None,
853            all_found: Vec::new(),
854            via_docker,
855        };
856    }
857
858    let mut found: Vec<PathBuf> = Vec::new();
859    // Common search locations. Order matters — first match wins for
860    // the diagnostic `library_path` field.
861    let candidates = [
862        "/usr/lib/x86_64-linux-gnu",
863        "/usr/local/lib",
864        "/usr/local/cuda/lib64",
865        "/opt/cuda/lib64",
866    ];
867    for dir in candidates {
868        let d = Path::new(dir);
869        if let Ok(entries) = std::fs::read_dir(d) {
870            for entry in entries.flatten() {
871                let name = entry.file_name();
872                let s = name.to_string_lossy();
873                if s.starts_with("libnccl.so") {
874                    found.push(entry.path());
875                }
876            }
877        }
878    }
879    // Honor LD_LIBRARY_PATH so user-shipped NCCL (the Pascal rig keeps
880    // libnccl.so under ~/nccl/build/lib for the CUDA-13 source build)
881    // is discovered.
882    if let Ok(paths) = std::env::var("LD_LIBRARY_PATH") {
883        for dir in paths.split(':').filter(|p| !p.is_empty()) {
884            let d = Path::new(dir);
885            if let Ok(entries) = std::fs::read_dir(d) {
886                for entry in entries.flatten() {
887                    let name = entry.file_name();
888                    let s = name.to_string_lossy();
889                    if s.starts_with("libnccl.so") {
890                        let p = entry.path();
891                        if !found.iter().any(|f| f == &p) {
892                            found.push(p);
893                        }
894                    }
895                }
896            }
897        }
898    }
899
900    if found.is_empty() {
901        issues.push(
902            "no `libnccl.so` found on standard library paths or \
903             $LD_LIBRARY_PATH. Multi-rank NCCL training will fail at \
904             collective init. Install libnccl matching your CUDA \
905             version or set LD_LIBRARY_PATH to a custom build (or \
906             declare `docker:` on this host in cluster.yml if NCCL \
907             ships inside the container image)."
908                .into(),
909        );
910    }
911
912    NcclStatus { library_path: found.first().cloned(), all_found: found, via_docker: None }
913}
914
915/// Best-effort filesystem-type lookup via `/proc/mounts`. Walks toward
916/// the root looking for the closest mount-point that contains `path`.
917/// Returns `None` on non-Linux or when `/proc/mounts` is unavailable.
918fn detect_fs_type(path: &Path) -> Option<String> {
919    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
920    let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
921    let mut best: Option<(usize, String)> = None;
922    for line in mounts.lines() {
923        let cols: Vec<&str> = line.split_whitespace().collect();
924        if cols.len() < 3 {
925            continue;
926        }
927        let mountpoint = Path::new(cols[1]);
928        let fs_type = cols[2].to_string();
929        if abs.starts_with(mountpoint) {
930            let depth = mountpoint.components().count();
931            match &best {
932                Some((prev_depth, _)) if depth <= *prev_depth => {}
933                _ => best = Some((depth, fs_type)),
934            }
935        }
936    }
937    best.map(|(_, t)| t)
938}
939
940// ---------------------------------------------------------------------------
941// Text output
942// ---------------------------------------------------------------------------
943
944fn print_report(r: &ProbeReport) {
945    println!("floDl Probe — {}", r.host);
946    println!("{}", "=".repeat(40));
947    println!();
948
949    println!("GPUs ({}):", r.gpus.len());
950    for g in &r.gpus {
951        println!(
952            "  [{}] {} — {}, {} MB",
953            g.index,
954            g.short_name(),
955            g.sm_version(),
956            g.total_memory_mb
957        );
958    }
959    println!();
960
961    println!("libtorch:");
962    match &r.libtorch.info {
963        Some(info) => {
964            println!("  path  : {}", info.path);
965            if let Some(t) = &info.torch_version {
966                println!("  torch : {}", t);
967            }
968            if let Some(c) = &info.cuda_version {
969                println!("  cuda  : {}", c);
970            }
971            if let Some(a) = &info.archs {
972                println!("  archs : {}", a);
973            }
974            if !r.libtorch.archs_match.is_empty() {
975                let ok = r.libtorch.archs_match.iter().filter(|(_, b)| *b).count();
976                println!(
977                    "  match : {}/{} GPUs covered",
978                    ok,
979                    r.libtorch.archs_match.len()
980                );
981            }
982            println!(
983                "  valid : {}",
984                if r.libtorch.valid_dir { "yes" } else { "no" }
985            );
986        }
987        None => println!("  (not configured)"),
988    }
989    println!();
990
991    println!("Shared data path:");
992    if r.data_path.skipped {
993        println!("  (skipped via --skip-mount)");
994    } else {
995        println!("  path     : {}", r.data_path.path.display());
996        println!("  exists   : {}", yn(r.data_path.exists));
997        println!("  readable : {}", yn(r.data_path.readable));
998        if let Some(t) = &r.data_path.fs_type {
999            println!("  fs       : {}", t);
1000        }
1001    }
1002    println!();
1003
1004    println!("NCCL:");
1005    if let Some(svc) = &r.nccl.via_docker {
1006        println!("  via Docker image `{}` (host check skipped)", svc);
1007    } else {
1008        match &r.nccl.library_path {
1009            Some(p) => {
1010                println!("  found    : {}", p.display());
1011                if r.nccl.all_found.len() > 1 {
1012                    println!("  others   : {} more (check for version skew)", r.nccl.all_found.len() - 1);
1013                }
1014            }
1015            None => println!("  (no libnccl.so* discovered)"),
1016        }
1017    }
1018    println!();
1019
1020    print_verdict_lines(&r.issues, &r.warnings);
1021}
1022
1023/// Render the three-tier verdict + numbered errors/warnings.
1024fn print_verdict_lines(issues: &[String], warnings: &[String]) {
1025    let n_err = issues.len();
1026    let n_warn = warnings.len();
1027    let line = match (n_err, n_warn) {
1028        (0, 0) => "verdict: READY".to_string(),
1029        (0, m) => format!("verdict: READY ({m} warning{})", plural(m)),
1030        (n, 0) => format!("verdict: ISSUES ({n} error{})", plural(n)),
1031        (n, m) => format!(
1032            "verdict: ISSUES ({n} error{}, {m} warning{})",
1033            plural(n),
1034            plural(m)
1035        ),
1036    };
1037    println!("{line}");
1038    if !issues.is_empty() {
1039        println!("errors:");
1040        for (i, msg) in issues.iter().enumerate() {
1041            println!("  {}. {}", i + 1, msg);
1042        }
1043    }
1044    if !warnings.is_empty() {
1045        println!("warnings:");
1046        for (i, msg) in warnings.iter().enumerate() {
1047            println!("  {}. {}", i + 1, msg);
1048        }
1049    }
1050}
1051
1052fn plural(n: usize) -> &'static str {
1053    if n == 1 { "" } else { "s" }
1054}
1055
1056fn yn(b: bool) -> &'static str {
1057    if b { "yes" } else { "no" }
1058}
1059
1060// ---------------------------------------------------------------------------
1061// JSON output (`fdl deploy` + CI consume this shape)
1062// ---------------------------------------------------------------------------
1063
1064fn print_json(r: &ProbeReport) {
1065    println!("{}", report_to_json_object(r));
1066}
1067
1068fn report_to_json_object(r: &ProbeReport) -> String {
1069    let mut b = String::with_capacity(2048);
1070    b.push('{');
1071    let _ = write!(b, "\"host\":\"{}\"", system::escape_json(&r.host));
1072
1073    // GPUs
1074    b.push_str(",\"gpus\":[");
1075    for (i, g) in r.gpus.iter().enumerate() {
1076        if i > 0 { b.push(','); }
1077        let _ = write!(
1078            b,
1079            "{{\"index\":{},\"name\":\"{}\",\"sm\":\"{}\",\"vram_mb\":{}}}",
1080            g.index,
1081            system::escape_json(&g.name),
1082            g.sm_version(),
1083            g.total_memory_mb
1084        );
1085    }
1086    b.push(']');
1087
1088    // libtorch
1089    b.push_str(",\"libtorch\":");
1090    match &r.libtorch.info {
1091        Some(info) => {
1092            let _ = write!(
1093                b,
1094                "{{\"path\":\"{}\",\"valid_dir\":{}",
1095                system::escape_json(&info.path),
1096                r.libtorch.valid_dir
1097            );
1098            if let Some(v) = &info.torch_version {
1099                let _ = write!(b, ",\"torch\":\"{}\"", system::escape_json(v));
1100            }
1101            if let Some(c) = &info.cuda_version {
1102                let _ = write!(b, ",\"cuda\":\"{}\"", system::escape_json(c));
1103            }
1104            if let Some(a) = &info.archs {
1105                let _ = write!(b, ",\"archs\":\"{}\"", system::escape_json(a));
1106            }
1107            b.push_str(",\"archs_match\":[");
1108            for (i, (gpu, ok)) in r.libtorch.archs_match.iter().enumerate() {
1109                if i > 0 { b.push(','); }
1110                let _ = write!(b, "{{\"gpu\":{},\"covered\":{}}}", gpu, ok);
1111            }
1112            b.push(']');
1113            b.push('}');
1114        }
1115        None => b.push_str("null"),
1116    }
1117
1118    // Shared data path
1119    b.push_str(",\"data_path\":");
1120    if r.data_path.skipped {
1121        b.push_str("null");
1122    } else {
1123        let _ = write!(
1124            b,
1125            "{{\"path\":\"{}\",\"exists\":{},\"readable\":{}",
1126            system::escape_json(&r.data_path.path.display().to_string()),
1127            r.data_path.exists,
1128            r.data_path.readable
1129        );
1130        if let Some(t) = &r.data_path.fs_type {
1131            let _ = write!(b, ",\"fs_type\":\"{}\"", system::escape_json(t));
1132        }
1133        b.push('}');
1134    }
1135
1136    // NCCL — always emit an object now (even when host scan was
1137    // skipped via Docker), so consumers can read `via_docker` without
1138    // null-checking.
1139    b.push_str(",\"nccl\":");
1140    if r.nccl.library_path.is_none() && r.nccl.via_docker.is_none() {
1141        b.push_str("null");
1142    } else {
1143        b.push('{');
1144        let mut first = true;
1145        if let Some(p) = &r.nccl.library_path {
1146            let _ = write!(
1147                b,
1148                "\"library_path\":\"{}\",\"count\":{}",
1149                system::escape_json(&p.display().to_string()),
1150                r.nccl.all_found.len()
1151            );
1152            first = false;
1153        }
1154        if let Some(svc) = &r.nccl.via_docker {
1155            if !first {
1156                b.push(',');
1157            }
1158            let _ = write!(b, "\"via_docker\":\"{}\"", system::escape_json(svc));
1159        }
1160        b.push('}');
1161    }
1162
1163    // Issues (errors) + warnings + verdict.
1164    b.push_str(",\"issues\":[");
1165    for (i, msg) in r.issues.iter().enumerate() {
1166        if i > 0 { b.push(','); }
1167        let _ = write!(b, "\"{}\"", system::escape_json(msg));
1168    }
1169    b.push(']');
1170    b.push_str(",\"warnings\":[");
1171    for (i, msg) in r.warnings.iter().enumerate() {
1172        if i > 0 { b.push(','); }
1173        let _ = write!(b, "\"{}\"", system::escape_json(msg));
1174    }
1175    b.push(']');
1176    let _ = write!(b, ",\"ready\":{}", r.green());
1177    b.push('}');
1178    b
1179}
1180
1181// ---------------------------------------------------------------------------
1182// Tests
1183// ---------------------------------------------------------------------------
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188
1189    #[test]
1190    fn data_path_check_skipped_when_flag_set() {
1191        let mut issues = Vec::new();
1192        let mut warnings = Vec::new();
1193        let status = check_data_path(
1194            PathBuf::from("/nonexistent"),
1195            true,
1196            false,
1197            &mut issues,
1198            &mut warnings,
1199        );
1200        assert!(status.skipped);
1201        assert!(issues.is_empty(), "skip_mount must suppress missing-path issue");
1202        assert!(warnings.is_empty(), "skip_mount must suppress missing-path warning");
1203    }
1204
1205    #[test]
1206    fn data_path_check_explicit_missing_is_error() {
1207        let mut issues = Vec::new();
1208        let mut warnings = Vec::new();
1209        let status = check_data_path(
1210            PathBuf::from("/this/should/never/exist/flodl-probe-test"),
1211            false,
1212            true, // explicit
1213            &mut issues,
1214            &mut warnings,
1215        );
1216        assert!(!status.exists);
1217        assert!(!status.readable);
1218        assert_eq!(issues.len(), 1, "explicit missing path → error");
1219        assert!(warnings.is_empty());
1220    }
1221
1222    #[test]
1223    fn data_path_check_default_missing_is_warning() {
1224        let mut issues = Vec::new();
1225        let mut warnings = Vec::new();
1226        let status = check_data_path(
1227            PathBuf::from("/this/should/never/exist/flodl-probe-test"),
1228            false,
1229            false, // convention default — not explicit
1230            &mut issues,
1231            &mut warnings,
1232        );
1233        assert!(!status.exists);
1234        assert!(issues.is_empty(), "default missing path must NOT error");
1235        assert_eq!(warnings.len(), 1, "default missing path → warning");
1236    }
1237
1238    #[test]
1239    fn data_path_check_reports_readable_tmp() {
1240        let mut issues = Vec::new();
1241        let mut warnings = Vec::new();
1242        let status = check_data_path(
1243            PathBuf::from("/tmp"),
1244            false,
1245            false,
1246            &mut issues,
1247            &mut warnings,
1248        );
1249        // /tmp is virtually always readable on Linux; if not we'd see
1250        // it in `issues` and the test would surface the surprise.
1251        assert!(status.exists);
1252        assert!(status.readable);
1253        assert!(issues.is_empty(), "issues = {:?}", issues);
1254        assert!(warnings.is_empty(), "warnings = {:?}", warnings);
1255    }
1256
1257    #[test]
1258    fn nccl_via_docker_skips_host_scan() {
1259        let mut issues = Vec::new();
1260        let status = check_nccl(Some("cuda".into()), &mut issues);
1261        assert!(issues.is_empty(), "docker-served NCCL must not produce errors");
1262        assert!(status.library_path.is_none());
1263        assert!(status.all_found.is_empty());
1264        assert_eq!(status.via_docker.as_deref(), Some("cuda"));
1265    }
1266
1267    #[test]
1268    fn verdict_format_three_tier() {
1269        // No errors, no warnings → READY.
1270        let r0 = ProbeReport {
1271            host: "h".into(),
1272            gpus: vec![],
1273            libtorch: LibtorchStatus { info: None, valid_dir: false, archs_match: vec![] },
1274            data_path: DataPathStatus {
1275                path: PathBuf::new(), exists: false, readable: false, fs_type: None, skipped: true,
1276            },
1277            nccl: NcclStatus { library_path: None, all_found: vec![], via_docker: None },
1278            issues: vec![],
1279            warnings: vec![],
1280        };
1281        assert!(r0.green());
1282
1283        // Warning-only is still green (exit 0).
1284        let r1 = ProbeReport { warnings: vec!["w".into()], ..clone_report(&r0) };
1285        assert!(r1.green());
1286
1287        // Error flips green to false.
1288        let r2 = ProbeReport { issues: vec!["e".into()], ..clone_report(&r0) };
1289        assert!(!r2.green());
1290    }
1291
1292    // Local clone helper — ProbeReport intentionally not Clone (Vec<GpuInfo>
1293    // has its own ownership).
1294    fn clone_report(r: &ProbeReport) -> ProbeReport {
1295        ProbeReport {
1296            host: r.host.clone(),
1297            gpus: vec![],
1298            libtorch: LibtorchStatus {
1299                info: None,
1300                valid_dir: r.libtorch.valid_dir,
1301                archs_match: vec![],
1302            },
1303            data_path: DataPathStatus {
1304                path: r.data_path.path.clone(),
1305                exists: r.data_path.exists,
1306                readable: r.data_path.readable,
1307                fs_type: r.data_path.fs_type.clone(),
1308                skipped: r.data_path.skipped,
1309            },
1310            nccl: NcclStatus {
1311                library_path: r.nccl.library_path.clone(),
1312                all_found: r.nccl.all_found.clone(),
1313                via_docker: r.nccl.via_docker.clone(),
1314            },
1315            issues: r.issues.clone(),
1316            warnings: r.warnings.clone(),
1317        }
1318    }
1319
1320    #[test]
1321    fn json_emits_warnings_array() {
1322        let r = ProbeReport {
1323            host: "h".into(),
1324            gpus: vec![],
1325            libtorch: LibtorchStatus { info: None, valid_dir: false, archs_match: vec![] },
1326            data_path: DataPathStatus {
1327                path: PathBuf::new(), exists: false, readable: false, fs_type: None, skipped: true,
1328            },
1329            nccl: NcclStatus { library_path: None, all_found: vec![], via_docker: Some("cuda".into()) },
1330            issues: vec![],
1331            warnings: vec!["data-path missing".into()],
1332        };
1333        let j = report_to_json_object(&r);
1334        let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
1335        assert!(v["ready"].as_bool().unwrap());
1336        let warns = v["warnings"].as_array().expect("warnings: []");
1337        assert_eq!(warns.len(), 1);
1338        assert_eq!(v["nccl"]["via_docker"].as_str(), Some("cuda"));
1339    }
1340
1341    #[test]
1342    fn json_survives_control_chars_in_names_and_paths() {
1343        // A tab / CR in a GPU name or mount path previously produced
1344        // invalid JSON that broke cluster probe fan-in.
1345        let r = ProbeReport {
1346            host: "h\tost".into(),
1347            gpus: vec![GpuInfo {
1348                index: 0,
1349                name: "Weird\tGPU \"X\"\r\n".into(),
1350                sm_major: 8,
1351                sm_minor: 6,
1352                total_memory_mb: 1024,
1353            }],
1354            libtorch: LibtorchStatus { info: None, valid_dir: false, archs_match: vec![] },
1355            data_path: DataPathStatus {
1356                path: PathBuf::from("/mnt/na\ts"), exists: true, readable: true,
1357                fs_type: Some("virtio\u{1}fs".into()), skipped: false,
1358            },
1359            nccl: NcclStatus { library_path: None, all_found: vec![], via_docker: None },
1360            issues: vec!["line1\nline2\ttabbed".into()],
1361            warnings: vec![],
1362        };
1363        let j = report_to_json_object(&r);
1364        let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
1365        assert_eq!(v["gpus"][0]["name"].as_str(), Some("Weird\tGPU \"X\"\r\n"));
1366        assert_eq!(v["data_path"]["fs_type"].as_str(), Some("virtio\u{1}fs"));
1367        assert_eq!(v["issues"][0].as_str(), Some("line1\nline2\ttabbed"));
1368    }
1369
1370    #[test]
1371    fn parse_remote_json_flags_schema_skew() {
1372        // A remote fdl speaking a different probe schema must surface as
1373        // version skew, not parse as a healthy zero-GPU host.
1374        let worker: ClusterWorker = serde_yaml_ng::from_str(
1375            "host: pascal\nlocal_devices: [0]\nnccl_socket_ifname: lo\npath: /opt/flodl",
1376        )
1377        .expect("minimal worker");
1378        let report = parse_remote_json(r#"{"something":"else"}"#, &worker)
1379            .expect("valid JSON parses");
1380        assert!(
1381            report.issues.iter().any(|i| i.contains("version skew")),
1382            "issues: {:?}",
1383            report.issues
1384        );
1385    }
1386
1387    #[test]
1388    fn fs_type_detected_for_root() {
1389        let t = detect_fs_type(Path::new("/"));
1390        // / is mounted on every Linux box; detection should not fail.
1391        // Skip on non-Linux (CI matrix) — /proc/mounts unavailable.
1392        if std::path::Path::new("/proc/mounts").exists() {
1393            assert!(t.is_some(), "expected fs_type for /");
1394        }
1395    }
1396}