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::requirements;
33use crate::util::system::{self, GpuInfo};
34use flodl_hw::{GpuArch, GpuVendor};
35
36pub 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 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 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
97fn 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 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 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
143fn 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 let mut remote_args: Vec<String> = vec!["fdl".into(), "probe".into(), "--json".into()];
164 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 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 if let Some(svc) = &worker.docker {
191 remote_args.push("--docker".into());
192 remote_args.push(svc.clone());
193 }
194 let quoted = remote_args
197 .iter()
198 .map(|a| crate::util::shell::posix_quote(a))
199 .collect::<Vec<_>>()
200 .join(" ");
201 let remote_cmd = format!(
207 "cd {} && {quoted}",
208 crate::util::shell::posix_quote(&worker.path),
209 );
210
211 let mut cmd = Command::new("ssh");
217 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 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
285fn 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 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 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 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 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
480fn 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
532pub 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 pub fn green(&self) -> bool {
560 self.issues.is_empty()
561 }
562}
563
564pub struct LibtorchStatus {
566 pub info: Option<LibtorchInfo>,
568 pub valid_dir: bool,
571 pub archs_match: Vec<(u8, bool)>,
574}
575
576pub struct DataPathStatus {
578 pub path: PathBuf,
579 pub exists: bool,
580 pub readable: bool,
581 pub fs_type: Option<String>,
586 pub skipped: bool,
590}
591
592pub struct NcclStatus {
597 pub library_path: Option<PathBuf>,
600 pub all_found: Vec<PathBuf>,
603 pub via_docker: Option<String>,
608}
609
610pub 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 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 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 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 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 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
740fn 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
804fn check_libtorch_at(path: &Path, gpus: &[GpuInfo], issues: &mut Vec<String>) -> LibtorchStatus {
818 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 let info = if root.join(".active").exists() {
867 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 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 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
979fn 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; };
1000
1001 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
1050fn 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 let install = crate::util::requirements::install_hint(&packages);
1081 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 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 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 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
1169pub(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 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
1191fn 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
1217pub(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
1242fn 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 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
1338fn 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
1375fn 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 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 g.sm_version().unwrap_or_default(),
1405 g.total_memory_mb
1406 );
1407 }
1408 b.push(']');
1409
1410 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 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 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 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#[cfg(test)]
1514mod tests {
1515 use super::*;
1516
1517 #[test]
1520 fn toolkit_warning_names_every_missing_header_and_its_package() {
1521 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 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 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 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 assert!(detect::variant_vendor("precompiled/cpu").is_none());
1633 assert!(detect::variant_vendor("precompiled/cpu-linux-aarch64").is_none());
1634 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, &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, &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 let status = check_data_path(
1710 std::env::temp_dir(),
1711 false,
1712 false,
1713 &mut issues,
1714 &mut warnings,
1715 );
1716 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 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 let r1 = ProbeReport {
1768 warnings: vec!["w".into()],
1769 ..clone_report(&r0)
1770 };
1771 assert!(r1.green());
1772
1773 let r2 = ProbeReport {
1775 issues: vec!["e".into()],
1776 ..clone_report(&r0)
1777 };
1778 assert!(!r2.green());
1779 }
1780
1781 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 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 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 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 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 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 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 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 assert!(mounted_at(Path::new("/")).is_some());
2015 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 assert_eq!(unescape_mount("odd\\"), "odd\\");
2030 assert_eq!(unescape_mount("odd\\9x"), "odd\\9x");
2031 }
2032}