1use 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
34pub 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 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 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
96fn 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 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 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
141fn 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 let mut remote_args: Vec<String> = vec![
162 "fdl".into(),
163 "probe".into(),
164 "--json".into(),
165 ];
166 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 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 if let Some(svc) = &worker.docker {
193 remote_args.push("--docker".into());
194 remote_args.push(svc.clone());
195 }
196 let quoted = remote_args
199 .iter()
200 .map(|a| crate::util::shell::posix_quote(a))
201 .collect::<Vec<_>>()
202 .join(" ");
203 let remote_cmd = format!(
209 "cd {} && {quoted}",
210 crate::util::shell::posix_quote(&worker.path),
211 );
212
213 let mut cmd = Command::new("ssh");
219 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 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
287fn 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 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 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 let n = s.trim_start_matches("sm_");
442 if let Ok(v) = n.parse::<u32>() {
443 let major = v / 10;
446 let minor = v % 10;
447 return (major, minor);
448 }
449 (0, 0)
450}
451
452fn 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
504pub 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 pub fn green(&self) -> bool {
532 self.issues.is_empty()
533 }
534}
535
536pub struct LibtorchStatus {
538 pub info: Option<LibtorchInfo>,
540 pub valid_dir: bool,
543 pub archs_match: Vec<(u8, bool)>,
546}
547
548pub struct DataPathStatus {
550 pub path: PathBuf,
551 pub exists: bool,
552 pub readable: bool,
553 pub fs_type: Option<String>,
558 pub skipped: bool,
562}
563
564pub struct NcclStatus {
569 pub library_path: Option<PathBuf>,
572 pub all_found: Vec<PathBuf>,
575 pub via_docker: Option<String>,
580}
581
582pub 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
647fn 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
677fn check_libtorch_at(
691 path: &Path,
692 gpus: &[GpuInfo],
693 issues: &mut Vec<String>,
694) -> LibtorchStatus {
695 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 let info = if root.join(".active").exists() {
742 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 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 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 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 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 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
915fn 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
940fn 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
1023fn 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
1060fn 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 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 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 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 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 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#[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, &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, &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 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 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 let r1 = ProbeReport { warnings: vec!["w".into()], ..clone_report(&r0) };
1285 assert!(r1.green());
1286
1287 let r2 = ProbeReport { issues: vec!["e".into()], ..clone_report(&r0) };
1289 assert!(!r2.green());
1290 }
1291
1292 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 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 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 if std::path::Path::new("/proc/mounts").exists() {
1393 assert!(t.is_some(), "expected fs_type for /");
1394 }
1395 }
1396}