use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cluster::resolve_local_hostname;
use crate::config::{self, ClusterWorker, DEFAULT_DATA_PATH};
use crate::context::Context;
use crate::libtorch::detect::{self, LibtorchInfo};
use crate::util::requirements;
use crate::util::system::{self, GpuInfo};
use flodl_hw::{GpuArch, GpuVendor};
pub fn run(
json: bool,
skip_mount: bool,
data_path_override: Option<PathBuf>,
libtorch_path_override: Option<PathBuf>,
via_docker: Option<String>,
) -> i32 {
let ctx = Context::resolve();
if libtorch_path_override.is_none()
&& let Ok(env_name) = std::env::var("FDL_ENV")
&& let Some(cluster) = load_cluster_for_env(&ctx, &env_name)
{
return run_cluster(&cluster, json, skip_mount);
}
let data_path_explicit = data_path_override.is_some();
let report = probe_local(
&ctx,
skip_mount,
data_path_override,
libtorch_path_override,
via_docker,
data_path_explicit,
);
if json {
print_json(&report);
} else {
print_report(&report);
}
if report.green() { 0 } else { 1 }
}
fn load_cluster_for_env(ctx: &Context, env_name: &str) -> Option<config::ClusterConfig> {
let config_path = config::find_config(&ctx.root)?;
let project = config::load_project_with_env(&config_path, Some(env_name)).ok()?;
project.cluster
}
fn run_cluster(cluster: &config::ClusterConfig, json: bool, skip_mount: bool) -> i32 {
let local = resolve_local_hostname();
let mut reports: Vec<ProbeReport> = Vec::with_capacity(cluster.workers.len());
for worker in &cluster.workers {
let r = if worker.host == local {
let ctx = Context::resolve();
let data_path_explicit = worker.data_path.is_some();
probe_local(
&ctx,
skip_mount,
worker.data_path.as_ref().map(PathBuf::from),
worker
.arch
.as_ref()
.map(|a| PathBuf::from(&worker.path).join("libtorch").join(a)),
worker.docker.clone(),
data_path_explicit,
)
} else {
probe_remote_via_ssh(worker, skip_mount)
};
reports.push(r);
}
let any_red = reports.iter().any(|r| !r.green());
if json {
print_cluster_json(&reports);
} else {
print_cluster_report(&reports);
}
if any_red { 1 } else { 0 }
}
fn probe_remote_via_ssh(worker: &ClusterWorker, skip_mount: bool) -> ProbeReport {
let ssh_target = worker
.ssh
.as_ref()
.and_then(|s| s.target.as_deref())
.unwrap_or(&worker.host)
.to_string();
let mut remote_args: Vec<String> = vec!["fdl".into(), "probe".into(), "--json".into()];
if let Some(dp) = &worker.data_path {
remote_args.push("--data-path".into());
remote_args.push(dp.clone());
}
if skip_mount {
remote_args.push("--skip-mount".into());
}
if let Some(arch) = &worker.arch {
remote_args.push("--libtorch-path".into());
remote_args.push(format!(
"{path}/libtorch/{arch}",
path = worker.path.trim_end_matches('/'),
));
}
if let Some(svc) = &worker.docker {
remote_args.push("--docker".into());
remote_args.push(svc.clone());
}
let quoted = remote_args
.iter()
.map(|a| crate::util::shell::posix_quote(a))
.collect::<Vec<_>>()
.join(" ");
let remote_cmd = format!(
"cd {} && {quoted}",
crate::util::shell::posix_quote(&worker.path),
);
let mut cmd = Command::new("ssh");
crate::cluster::apply_worker_ssh_opts(&mut cmd, worker);
cmd.args([
"-T",
"-o",
"BatchMode=yes",
"-o",
"ServerAliveInterval=10",
"-o",
"ServerAliveCountMax=3",
]);
cmd.arg(&ssh_target).arg(&remote_cmd);
let output = cmd.output();
let mut report = ProbeReport {
host: worker.host.clone(),
gpus: Vec::new(),
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: Vec::new(),
},
data_path: DataPathStatus {
path: PathBuf::from(worker.effective_data_path()),
exists: false,
readable: false,
fs_type: None,
skipped: skip_mount,
},
nccl: NcclStatus {
library_path: None,
all_found: Vec::new(),
via_docker: worker.docker.clone(),
},
issues: Vec::new(),
warnings: Vec::new(),
};
match output {
Err(e) => {
report.issues.push(format!(
"ssh to `{ssh_target}` failed before probe ran: {e}"
));
}
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
match parse_remote_json(&stdout, worker) {
Ok(r) => report = r,
Err(parse_err) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
report.issues.push(format!(
"remote probe on `{ssh_target}` exited {} — \
stdout did not parse as JSON ({parse_err}); \
stderr: {stderr}; first 200 chars of stdout: {:?}",
out.status,
stdout.chars().take(200).collect::<String>(),
));
}
}
}
}
report
}
fn parse_remote_json(json: &str, worker: &ClusterWorker) -> Result<ProbeReport, String> {
let v: serde_json::Value =
serde_json::from_str(json.trim()).map_err(|e| format!("JSON parse: {e}"))?;
let mut report = ProbeReport {
host: worker.host.clone(),
gpus: Vec::new(),
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: Vec::new(),
},
data_path: DataPathStatus {
path: PathBuf::from(worker.effective_data_path()),
exists: false,
readable: false,
fs_type: None,
skipped: false,
},
nccl: NcclStatus {
library_path: None,
all_found: Vec::new(),
via_docker: worker.docker.clone(),
},
issues: Vec::new(),
warnings: Vec::new(),
};
if let Some(gpus) = v.get("gpus").and_then(|g| g.as_array()) {
for g in gpus {
let index = g.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let name = g
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let total_memory_mb = g.get("vram_mb").and_then(|v| v.as_u64()).unwrap_or(0);
let vendor = g
.get("vendor")
.and_then(|v| v.as_str())
.and_then(GpuVendor::parse)
.unwrap_or(GpuVendor::Nvidia);
let token = g
.get("arch")
.and_then(|v| v.as_str())
.or_else(|| g.get("sm").and_then(|v| v.as_str()))
.unwrap_or_default();
let Some(arch) = GpuArch::parse(vendor, token) else {
report.warnings.push(format!(
"host {:?}: GPU {index} reports an unrecognized {vendor} arch \
{token:?}; skipping it in the report",
worker.host,
));
continue;
};
report.gpus.push(GpuInfo {
index,
vendor,
name,
arch,
total_memory_mb,
});
}
}
if let Some(lt) = v.get("libtorch")
&& !lt.is_null()
{
let path = lt
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let valid_dir = lt
.get("valid_dir")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let info = LibtorchInfo {
path,
torch_version: lt.get("torch").and_then(|v| v.as_str()).map(String::from),
cuda_version: lt.get("cuda").and_then(|v| v.as_str()).map(String::from),
archs: lt.get("archs").and_then(|v| v.as_str()).map(String::from),
source: None,
};
let mut archs_match = Vec::new();
if let Some(am) = lt.get("archs_match").and_then(|v| v.as_array()) {
for entry in am {
let gpu = entry.get("gpu").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
let covered = entry
.get("covered")
.and_then(|v| v.as_bool())
.unwrap_or(false);
archs_match.push((gpu, covered));
}
}
report.libtorch = LibtorchStatus {
info: Some(info),
valid_dir,
archs_match,
};
}
if let Some(dp) = v.get("data_path") {
if !dp.is_null() {
let path = dp
.get("path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(worker.effective_data_path()));
let exists = dp.get("exists").and_then(|v| v.as_bool()).unwrap_or(false);
let readable = dp
.get("readable")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let fs_type = dp.get("fs_type").and_then(|v| v.as_str()).map(String::from);
report.data_path = DataPathStatus {
path,
exists,
readable,
fs_type,
skipped: false,
};
} else {
report.data_path.skipped = true;
}
}
if let Some(nccl) = v.get("nccl")
&& !nccl.is_null()
{
let p = nccl
.get("library_path")
.and_then(|v| v.as_str())
.map(PathBuf::from);
report.nccl.library_path = p.clone();
if let Some(p) = p {
report.nccl.all_found.push(p);
}
if let Some(svc) = nccl.get("via_docker").and_then(|v| v.as_str()) {
report.nccl.via_docker = Some(svc.to_string());
}
}
if let Some(issues) = v.get("issues").and_then(|v| v.as_array()) {
for i in issues {
if let Some(s) = i.as_str() {
report.issues.push(s.to_string());
}
}
}
if let Some(warnings) = v.get("warnings").and_then(|v| v.as_array()) {
for w in warnings {
if let Some(s) = w.as_str() {
report.warnings.push(s.to_string());
}
}
}
for key in ["gpus", "ready"] {
if v.get(key).is_none() {
report.issues.push(format!(
"remote probe JSON has no {key:?} field — the remote fdl \
likely speaks a different probe schema (version skew); \
update fdl on `{}`",
worker.host
));
}
}
Ok(report)
}
fn print_cluster_report(reports: &[ProbeReport]) {
println!("floDl Cluster Probe — {} hosts", reports.len());
println!("{}", "=".repeat(40));
println!();
for (i, r) in reports.iter().enumerate() {
if i > 0 {
println!();
println!("{}", "-".repeat(40));
println!();
}
print_report(r);
}
println!();
let red = reports.iter().filter(|r| !r.green()).count();
let yellow = reports
.iter()
.filter(|r| r.green() && !r.warnings.is_empty())
.count();
let total = reports.len();
match (red, yellow) {
(0, 0) => println!("CLUSTER VERDICT: READY (all {total} hosts green)"),
(0, y) => println!("CLUSTER VERDICT: READY ({y}/{total} hosts have warnings)"),
(r, 0) => println!("CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors)"),
(r, y) => println!(
"CLUSTER VERDICT: ISSUES ({r}/{total} hosts have errors, \
{y} also have warnings)"
),
}
}
fn print_cluster_json(reports: &[ProbeReport]) {
let mut b = String::with_capacity(4096);
b.push_str("{\"hosts\":[");
for (i, r) in reports.iter().enumerate() {
if i > 0 {
b.push(',');
}
b.push_str(&report_to_json_object(r));
}
b.push(']');
let red = reports.iter().filter(|r| !r.green()).count();
let _ = write!(b, ",\"hosts_total\":{}", reports.len());
let _ = write!(b, ",\"hosts_red\":{}", red);
let _ = write!(b, ",\"ready\":{}", red == 0);
b.push('}');
println!("{}", b);
}
pub struct ProbeReport {
pub host: String,
pub gpus: Vec<GpuInfo>,
pub libtorch: LibtorchStatus,
pub data_path: DataPathStatus,
pub nccl: NcclStatus,
pub issues: Vec<String>,
pub warnings: Vec<String>,
}
impl ProbeReport {
pub fn green(&self) -> bool {
self.issues.is_empty()
}
}
pub struct LibtorchStatus {
pub info: Option<LibtorchInfo>,
pub valid_dir: bool,
pub archs_match: Vec<(u8, bool)>,
}
pub struct DataPathStatus {
pub path: PathBuf,
pub exists: bool,
pub readable: bool,
pub fs_type: Option<String>,
pub skipped: bool,
}
pub struct NcclStatus {
pub library_path: Option<PathBuf>,
pub all_found: Vec<PathBuf>,
pub via_docker: Option<String>,
}
pub fn probe_local(
ctx: &Context,
skip_mount: bool,
data_path_override: Option<PathBuf>,
libtorch_path_override: Option<PathBuf>,
via_docker: Option<String>,
data_path_explicit: bool,
) -> ProbeReport {
let host = resolve_local_hostname();
let mut issues: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let sweep = flodl_hw::survey();
for note in &sweep.notes {
if note.kind.explains_absence() {
issues.push(note.to_string());
} else {
warnings.push(note.to_string());
}
}
let has_nvidia = sweep.has_vendor(GpuVendor::Nvidia);
let gpus = sweep.devices;
let libtorch = match libtorch_path_override {
Some(p) => check_libtorch_at(&p, &gpus, &mut issues),
None => check_libtorch(&ctx.root, &gpus, &mut issues),
};
let data_path = check_data_path(
data_path_override.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_PATH)),
skip_mount,
data_path_explicit,
&mut issues,
&mut warnings,
);
let nccl = if !has_nvidia {
NcclStatus {
library_path: None,
all_found: vec![],
via_docker: None,
}
} else {
check_nccl(via_docker, &mut issues)
};
if gpus.is_empty() {
issues.push(
"no usable GPUs detected. Single-host CPU training will still \
work; multi-rank training requires a working GPU stack."
.into(),
);
}
check_gpu_toolkit(libtorch.info.as_ref(), &mut warnings);
let tools = requirements::missing_host_tools();
if !tools.is_empty() {
issues.push(format!(
"missing host tools `fdl` needs: {}. Install with `sudo apt install {}` \
(or the equivalent for your distribution).",
tools.join(", "),
tools.join(" "),
));
}
ProbeReport {
host,
gpus,
libtorch,
data_path,
nccl,
issues,
warnings,
}
}
fn push_loader_issue(variant_dir: &Path, label: &str, issues: &mut Vec<String>) {
let unmet = detect::unmet_loader_requirements(variant_dir);
if unmet.is_empty() {
return;
}
issues.push(format!(
"libtorch variant `{label}` cannot load on this host: the dynamic \
linker is missing {}. The archive was built against a newer C \
library than this distribution ships, so it compiles and links and \
then fails to start. Use a variant with an older baseline (cpu and \
cu128 need less than the rocm archives) or a newer distribution.",
unmet.join(", "),
));
}
fn libtorch_status_from_info(
info: Option<LibtorchInfo>,
libtorch_root: &Path,
gpus: &[GpuInfo],
issues: &mut Vec<String>,
) -> LibtorchStatus {
let valid_dir = match &info {
Some(i) => libtorch_root.join(&i.path).join("lib").is_dir(),
None => false,
};
if let Some(i) = &info {
push_loader_issue(&libtorch_root.join(&i.path), &i.path, issues);
}
let archs_match = match &info {
Some(i) => detect::arch_coverage(i, gpus, issues),
None => {
issues.push(
"libtorch pointer file did not resolve to a configured \
variant (file empty or missing). Check the `.active*` \
content names a real subdir under `libtorch/`."
.into(),
);
Vec::new()
}
};
LibtorchStatus {
info,
valid_dir,
archs_match,
}
}
fn check_libtorch_at(path: &Path, gpus: &[GpuInfo], issues: &mut Vec<String>) -> LibtorchStatus {
if path.is_file()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(".active"))
{
let libtorch_root = path.parent().unwrap_or(path);
let info = detect::read_active_from(path, libtorch_root);
return libtorch_status_from_info(info, libtorch_root, gpus, issues);
}
if path.join(".active").exists() {
return check_libtorch(path, gpus, issues);
}
let dir = path;
let valid_dir = dir.join("lib").is_dir();
if !valid_dir {
issues.push(format!(
"libtorch directory `{}` does not contain `lib/` — pass \
`--libtorch-path` pointing at a real libtorch install \
(the directory with `lib/libtorch.so`).",
dir.display()
));
return LibtorchStatus {
info: None,
valid_dir: false,
archs_match: Vec::new(),
};
}
let info = detect::libtorch_info_from_dir(dir.display().to_string(), dir);
let archs_match = detect::arch_coverage(&info, gpus, issues);
push_loader_issue(dir, &info.path, issues);
LibtorchStatus {
info: Some(info),
valid_dir: true,
archs_match,
}
}
fn check_libtorch(root: &Path, gpus: &[GpuInfo], issues: &mut Vec<String>) -> LibtorchStatus {
let info = if root.join(".active").exists() {
let active_text = std::fs::read_to_string(root.join(".active")).ok();
match active_text {
Some(t) => {
let variant = t.trim().to_string();
if variant.is_empty() {
None
} else {
let arch_dir = root.join(&variant);
Some(detect::libtorch_info_from_dir(variant, &arch_dir))
}
}
None => None,
}
} else {
detect::read_active(root)
};
let valid_dir = match &info {
Some(i) => {
if root.join(".active").exists() {
root.join(&i.path).join("lib").is_dir()
} else {
detect::is_valid_variant(root, &i.path)
}
}
None => false,
};
let archs_match = match &info {
Some(i) => detect::arch_coverage(i, gpus, issues),
None => {
issues.push(
"libtorch not configured — `libtorch/.active` missing or \
empty. Run `fdl libtorch download` or `fdl libtorch build` \
to provision a variant."
.into(),
);
Vec::new()
}
};
LibtorchStatus {
info,
valid_dir,
archs_match,
}
}
fn check_data_path(
path: PathBuf,
skip_mount: bool,
explicit: bool,
issues: &mut Vec<String>,
warnings: &mut Vec<String>,
) -> DataPathStatus {
if skip_mount {
return DataPathStatus {
path: PathBuf::new(),
exists: false,
readable: false,
fs_type: None,
skipped: true,
};
}
let exists = path.exists();
let readable = exists && std::fs::read_dir(&path).is_ok();
let fs_type = detect_fs_type(&path);
if !exists {
if explicit {
issues.push(format!(
"shared data path `{}` does not exist on this host. flodl \
assumes a shared filesystem (NAS / SMB / virtiofs / SSHFS) \
mounted at the same logical path on every node. Mount the \
shared storage or correct `data_path:` in cluster.yml.",
path.display()
));
} else {
warnings.push(format!(
"convention shared-data path `{}` not present on this host \
(no `data_path:` declared in cluster.yml). Ignore if you \
don't use shared storage; otherwise set `data_path:` per \
host or mount `{}`.",
path.display(),
path.display()
));
}
} else if !readable {
issues.push(format!(
"shared data path `{}` exists but is not readable by the \
current user. Check mount permissions / uid mapping.",
path.display()
));
}
DataPathStatus {
path,
exists,
readable,
fs_type,
skipped: false,
}
}
fn check_gpu_toolkit(info: Option<&LibtorchInfo>, warnings: &mut Vec<String>) {
let Some(info) = info else { return };
let Some(vendor) = detect::variant_vendor(&info.path) else {
return; };
let plan = match vendor {
GpuVendor::Amd => Some((
"ROCM_PATH",
flodl_hw::rocm_runtime_root()
.map(|p| p.display().to_string())
.or_else(|| std::env::var("ROCM_PATH").ok())
.unwrap_or_else(|| "/opt/rocm".to_string()),
crate::util::requirements::ROCM_HEADERS,
None,
"rocm",
)),
GpuVendor::Nvidia => Some((
"CUDA_HOME",
std::env::var("CUDA_HOME").unwrap_or_else(|_| "/usr/local/cuda".to_string()),
crate::util::requirements::CUDA_HEADERS,
Some("cuda-toolkit libnccl-dev"),
"cuda",
)),
_ => None,
};
let Some((root_env, root, headers, metapackages, feature)) = plan else {
return;
};
if let Some(w) = gpu_toolkit_warning(
&info.path,
Path::new(&root),
root_env,
headers,
metapackages,
feature,
) {
warnings.push(w);
}
}
fn gpu_toolkit_warning(
variant: &str,
root: &Path,
root_env: &str,
headers: &[(&str, &str)],
metapackages: Option<&str>,
feature: &str,
) -> Option<String> {
let missing = crate::util::requirements::missing_headers(root, headers);
if missing.is_empty() {
return None;
}
let packages: Vec<String> = match metapackages {
Some(m) => m.split_whitespace().map(str::to_string).collect(),
None => crate::util::requirements::packages_for(&missing),
};
let list: Vec<&str> = missing.iter().map(|(h, _)| *h).collect();
let root = root.display();
let install = crate::util::requirements::install_hint(&packages);
Some(format!(
"active libtorch is `{}` but its toolkit headers are missing under \
`{root}` ({}). Native builds with `--features {feature}` will fail; \
building in the dev container is unaffected. Install them with: \
{install}. Set {root_env} if your install is elsewhere.",
variant,
list.join(", "),
))
}
fn check_nccl(via_docker: Option<String>, issues: &mut Vec<String>) -> NcclStatus {
if via_docker.is_some() {
return NcclStatus {
library_path: None,
all_found: Vec::new(),
via_docker,
};
}
let mut found: Vec<PathBuf> = Vec::new();
let candidates = [
"/usr/lib/x86_64-linux-gnu",
"/usr/local/lib",
"/usr/local/cuda/lib64",
"/opt/cuda/lib64",
];
for dir in candidates {
let d = Path::new(dir);
if let Ok(entries) = std::fs::read_dir(d) {
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with("libnccl.so") {
found.push(entry.path());
}
}
}
}
if let Ok(paths) = std::env::var("LD_LIBRARY_PATH") {
for dir in paths.split(':').filter(|p| !p.is_empty()) {
let d = Path::new(dir);
if let Ok(entries) = std::fs::read_dir(d) {
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with("libnccl.so") {
let p = entry.path();
if !found.iter().any(|f| f == &p) {
found.push(p);
}
}
}
}
}
}
if found.is_empty() {
issues.push(
"no `libnccl.so` found on standard library paths or \
$LD_LIBRARY_PATH. Multi-rank NCCL training will fail at \
collective init. Install libnccl matching your CUDA \
version or set LD_LIBRARY_PATH to a custom build (or \
declare `docker:` on this host in cluster.yml if NCCL \
ships inside the container image)."
.into(),
);
}
NcclStatus {
library_path: found.first().cloned(),
all_found: found,
via_docker: None,
}
}
pub(crate) fn mounted_at(path: &Path) -> Option<(String, String)> {
let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let mut found = None;
for line in mounts.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 3 && Path::new(cols[1]) == abs {
found = Some((unescape_mount(cols[0]), cols[2].to_string()));
}
}
found
}
fn unescape_mount(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let mut chars = field.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
let digits: String = chars.clone().take(3).collect();
match u8::from_str_radix(&digits, 8) {
Ok(byte) if digits.len() == 3 => {
out.push(byte as char);
for _ in 0..3 {
chars.next();
}
}
_ => out.push(c),
}
}
out
}
pub(crate) fn detect_fs_type(path: &Path) -> Option<String> {
let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let mut best: Option<(usize, String)> = None;
for line in mounts.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() < 3 {
continue;
}
let mountpoint = Path::new(cols[1]);
let fs_type = cols[2].to_string();
if abs.starts_with(mountpoint) {
let depth = mountpoint.components().count();
match &best {
Some((prev_depth, _)) if depth <= *prev_depth => {}
_ => best = Some((depth, fs_type)),
}
}
}
best.map(|(_, t)| t)
}
fn print_report(r: &ProbeReport) {
println!("floDl Probe — {}", r.host);
println!("{}", "=".repeat(40));
println!();
println!("GPUs ({}):", r.gpus.len());
for g in &r.gpus {
println!(
" [{}] {} — {}, {} MB",
g.index,
g.short_name(),
g.arch_label(),
g.total_memory_mb
);
}
println!();
println!("libtorch:");
match &r.libtorch.info {
Some(info) => {
println!(" path : {}", info.path);
if let Some(t) = &info.torch_version {
println!(" torch : {}", t);
}
match detect::variant_vendor(&info.path) {
Some(v) => println!(" vendor: {}", v),
None => println!(" vendor: CPU-only"),
}
if let Some(c) = info.cuda_version.as_deref().filter(|c| *c != "none") {
println!(" cuda : {}", c);
}
if let Some(a) = &info.archs {
println!(" archs : {}", a);
}
if !r.libtorch.archs_match.is_empty() {
let ok = r.libtorch.archs_match.iter().filter(|(_, b)| *b).count();
println!(
" match : {}/{} GPUs covered",
ok,
r.libtorch.archs_match.len()
);
}
println!(
" valid : {}",
if r.libtorch.valid_dir { "yes" } else { "no" }
);
}
None => println!(" (not configured)"),
}
println!();
println!("Shared data path:");
if r.data_path.skipped {
println!(" (skipped via --skip-mount)");
} else {
println!(" path : {}", r.data_path.path.display());
println!(" exists : {}", yn(r.data_path.exists));
println!(" readable : {}", yn(r.data_path.readable));
if let Some(t) = &r.data_path.fs_type {
println!(" fs : {}", t);
}
}
println!();
println!("NCCL:");
if let Some(svc) = &r.nccl.via_docker {
println!(" via Docker image `{}` (host check skipped)", svc);
} else {
match &r.nccl.library_path {
Some(p) => {
println!(" found : {}", p.display());
if r.nccl.all_found.len() > 1 {
println!(
" others : {} more (check for version skew)",
r.nccl.all_found.len() - 1
);
}
}
None => println!(" (no libnccl.so* discovered)"),
}
}
println!();
print_verdict_lines(&r.issues, &r.warnings);
}
fn print_verdict_lines(issues: &[String], warnings: &[String]) {
let n_err = issues.len();
let n_warn = warnings.len();
let line = match (n_err, n_warn) {
(0, 0) => "verdict: READY".to_string(),
(0, m) => format!("verdict: READY ({m} warning{})", plural(m)),
(n, 0) => format!("verdict: ISSUES ({n} error{})", plural(n)),
(n, m) => format!(
"verdict: ISSUES ({n} error{}, {m} warning{})",
plural(n),
plural(m)
),
};
println!("{line}");
if !issues.is_empty() {
println!("errors:");
for (i, msg) in issues.iter().enumerate() {
println!(" {}. {}", i + 1, msg);
}
}
if !warnings.is_empty() {
println!("warnings:");
for (i, msg) in warnings.iter().enumerate() {
println!(" {}. {}", i + 1, msg);
}
}
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn yn(b: bool) -> &'static str {
if b { "yes" } else { "no" }
}
fn print_json(r: &ProbeReport) {
println!("{}", report_to_json_object(r));
}
fn report_to_json_object(r: &ProbeReport) -> String {
let mut b = String::with_capacity(2048);
b.push('{');
let _ = write!(b, "\"host\":\"{}\"", system::escape_json(&r.host));
b.push_str(",\"gpus\":[");
for (i, g) in r.gpus.iter().enumerate() {
if i > 0 {
b.push(',');
}
let _ = write!(
b,
"{{\"index\":{},\"name\":\"{}\",\"vendor\":\"{}\",\"arch\":\"{}\",\"sm\":\"{}\",\"vram_mb\":{}}}",
g.index,
system::escape_json(&g.name),
g.vendor.as_str(),
g.arch_label(),
g.sm_version().unwrap_or_default(),
g.total_memory_mb
);
}
b.push(']');
b.push_str(",\"libtorch\":");
match &r.libtorch.info {
Some(info) => {
let _ = write!(
b,
"{{\"path\":\"{}\",\"valid_dir\":{}",
system::escape_json(&info.path),
r.libtorch.valid_dir
);
if let Some(v) = &info.torch_version {
let _ = write!(b, ",\"torch\":\"{}\"", system::escape_json(v));
}
if let Some(c) = &info.cuda_version {
let _ = write!(b, ",\"cuda\":\"{}\"", system::escape_json(c));
}
if let Some(a) = &info.archs {
let _ = write!(b, ",\"archs\":\"{}\"", system::escape_json(a));
}
b.push_str(",\"archs_match\":[");
for (i, (gpu, ok)) in r.libtorch.archs_match.iter().enumerate() {
if i > 0 {
b.push(',');
}
let _ = write!(b, "{{\"gpu\":{},\"covered\":{}}}", gpu, ok);
}
b.push(']');
b.push('}');
}
None => b.push_str("null"),
}
b.push_str(",\"data_path\":");
if r.data_path.skipped {
b.push_str("null");
} else {
let _ = write!(
b,
"{{\"path\":\"{}\",\"exists\":{},\"readable\":{}",
system::escape_json(&r.data_path.path.display().to_string()),
r.data_path.exists,
r.data_path.readable
);
if let Some(t) = &r.data_path.fs_type {
let _ = write!(b, ",\"fs_type\":\"{}\"", system::escape_json(t));
}
b.push('}');
}
b.push_str(",\"nccl\":");
if r.nccl.library_path.is_none() && r.nccl.via_docker.is_none() {
b.push_str("null");
} else {
b.push('{');
let mut first = true;
if let Some(p) = &r.nccl.library_path {
let _ = write!(
b,
"\"library_path\":\"{}\",\"count\":{}",
system::escape_json(&p.display().to_string()),
r.nccl.all_found.len()
);
first = false;
}
if let Some(svc) = &r.nccl.via_docker {
if !first {
b.push(',');
}
let _ = write!(b, "\"via_docker\":\"{}\"", system::escape_json(svc));
}
b.push('}');
}
b.push_str(",\"issues\":[");
for (i, msg) in r.issues.iter().enumerate() {
if i > 0 {
b.push(',');
}
let _ = write!(b, "\"{}\"", system::escape_json(msg));
}
b.push(']');
b.push_str(",\"warnings\":[");
for (i, msg) in r.warnings.iter().enumerate() {
if i > 0 {
b.push(',');
}
let _ = write!(b, "\"{}\"", system::escape_json(msg));
}
b.push(']');
let _ = write!(b, ",\"ready\":{}", r.green());
b.push('}');
b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toolkit_warning_names_every_missing_header_and_its_package() {
let root = PathBuf::from("/nonexistent/flodl-probe-test/rocm");
let w = gpu_toolkit_warning(
"precompiled/rocm70",
&root,
"ROCM_PATH",
crate::util::requirements::ROCM_HEADERS,
None,
"rocm",
)
.expect("absent toolkit must warn");
for (header, _) in crate::util::requirements::ROCM_HEADERS {
assert!(w.contains(header), "missing header {header}: {w}");
}
assert!(w.contains("precompiled/rocm70"), "{w}");
assert!(w.contains("ROCM_PATH"), "{w}");
let packages = crate::util::requirements::packages_for(
&crate::util::requirements::ROCM_HEADERS
.iter()
.collect::<Vec<_>>(),
);
let hint = crate::util::requirements::install_hint(&packages);
assert!(w.contains(&hint), "install line not `{hint}`: {w}");
}
#[test]
fn toolkit_warning_says_the_container_path_is_unaffected() {
let root = PathBuf::from("/nonexistent/flodl-probe-test/cuda");
let w = gpu_toolkit_warning(
"precompiled/cu128",
&root,
"CUDA_HOME",
&[("cuda_runtime.h", "cuda-cudart-dev-<M>-<m>")],
Some("cuda-toolkit libnccl-dev"),
"cuda",
)
.unwrap();
assert!(w.contains("dev container is unaffected"), "{w}");
assert!(w.contains("--features cuda"), "{w}");
let hint = crate::util::requirements::install_hint(&[
"cuda-toolkit".to_string(),
"libnccl-dev".to_string(),
]);
assert!(w.contains(&hint), "metapackage line not `{hint}`: {w}");
assert!(
!w.contains("<M>-<m>"),
"placeholders must not reach the user: {w}"
);
}
#[test]
fn toolkit_present_warns_nothing_and_partial_reports_only_the_gap() {
let root = std::env::temp_dir().join(format!("fdl-probe-toolkit-{}", std::process::id()));
std::fs::create_dir_all(root.join("include/hip")).unwrap();
std::fs::write(root.join("include/hip/hip_runtime.h"), "//").unwrap();
assert!(
gpu_toolkit_warning(
"precompiled/rocm70",
&root,
"ROCM_PATH",
&[("hip/hip_runtime.h", "hip-dev")],
None,
"rocm",
)
.is_none(),
"a present header must not warn"
);
let w = gpu_toolkit_warning(
"precompiled/rocm70",
&root,
"ROCM_PATH",
&[
("hip/hip_runtime.h", "hip-dev"),
("rccl/rccl.h", "rccl-dev"),
],
None,
"rocm",
)
.expect("one missing header is still a warning");
assert!(w.contains("rccl/rccl.h"), "{w}");
assert!(
!w.contains("hip_runtime"),
"must not list the header it found: {w}"
);
assert!(!w.contains("hip-dev"), "nor the package it owns: {w}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn cpu_variant_wants_no_toolkit() {
assert!(detect::variant_vendor("precompiled/cpu").is_none());
assert!(detect::variant_vendor("precompiled/cpu-linux-aarch64").is_none());
assert_eq!(
detect::variant_vendor("precompiled/rocm70"),
Some(GpuVendor::Amd)
);
assert_eq!(
detect::variant_vendor("precompiled/cu128"),
Some(GpuVendor::Nvidia)
);
}
#[test]
fn data_path_check_skipped_when_flag_set() {
let mut issues = Vec::new();
let mut warnings = Vec::new();
let status = check_data_path(
PathBuf::from("/nonexistent"),
true,
false,
&mut issues,
&mut warnings,
);
assert!(status.skipped);
assert!(
issues.is_empty(),
"skip_mount must suppress missing-path issue"
);
assert!(
warnings.is_empty(),
"skip_mount must suppress missing-path warning"
);
}
#[test]
fn data_path_check_explicit_missing_is_error() {
let mut issues = Vec::new();
let mut warnings = Vec::new();
let status = check_data_path(
PathBuf::from("/this/should/never/exist/flodl-probe-test"),
false,
true, &mut issues,
&mut warnings,
);
assert!(!status.exists);
assert!(!status.readable);
assert_eq!(issues.len(), 1, "explicit missing path → error");
assert!(warnings.is_empty());
}
#[test]
fn data_path_check_default_missing_is_warning() {
let mut issues = Vec::new();
let mut warnings = Vec::new();
let status = check_data_path(
PathBuf::from("/this/should/never/exist/flodl-probe-test"),
false,
false, &mut issues,
&mut warnings,
);
assert!(!status.exists);
assert!(issues.is_empty(), "default missing path must NOT error");
assert_eq!(warnings.len(), 1, "default missing path → warning");
}
#[test]
fn data_path_check_reports_readable_tmp() {
let mut issues = Vec::new();
let mut warnings = Vec::new();
let status = check_data_path(
std::env::temp_dir(),
false,
false,
&mut issues,
&mut warnings,
);
assert!(status.exists);
assert!(status.readable);
assert!(issues.is_empty(), "issues = {:?}", issues);
assert!(warnings.is_empty(), "warnings = {:?}", warnings);
}
#[test]
fn nccl_via_docker_skips_host_scan() {
let mut issues = Vec::new();
let status = check_nccl(Some("cuda".into()), &mut issues);
assert!(
issues.is_empty(),
"docker-served NCCL must not produce errors"
);
assert!(status.library_path.is_none());
assert!(status.all_found.is_empty());
assert_eq!(status.via_docker.as_deref(), Some("cuda"));
}
#[test]
fn verdict_format_three_tier() {
let r0 = ProbeReport {
host: "h".into(),
gpus: vec![],
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: vec![],
},
data_path: DataPathStatus {
path: PathBuf::new(),
exists: false,
readable: false,
fs_type: None,
skipped: true,
},
nccl: NcclStatus {
library_path: None,
all_found: vec![],
via_docker: None,
},
issues: vec![],
warnings: vec![],
};
assert!(r0.green());
let r1 = ProbeReport {
warnings: vec!["w".into()],
..clone_report(&r0)
};
assert!(r1.green());
let r2 = ProbeReport {
issues: vec!["e".into()],
..clone_report(&r0)
};
assert!(!r2.green());
}
fn clone_report(r: &ProbeReport) -> ProbeReport {
ProbeReport {
host: r.host.clone(),
gpus: vec![],
libtorch: LibtorchStatus {
info: None,
valid_dir: r.libtorch.valid_dir,
archs_match: vec![],
},
data_path: DataPathStatus {
path: r.data_path.path.clone(),
exists: r.data_path.exists,
readable: r.data_path.readable,
fs_type: r.data_path.fs_type.clone(),
skipped: r.data_path.skipped,
},
nccl: NcclStatus {
library_path: r.nccl.library_path.clone(),
all_found: r.nccl.all_found.clone(),
via_docker: r.nccl.via_docker.clone(),
},
issues: r.issues.clone(),
warnings: r.warnings.clone(),
}
}
#[test]
fn json_emits_warnings_array() {
let r = ProbeReport {
host: "h".into(),
gpus: vec![],
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: vec![],
},
data_path: DataPathStatus {
path: PathBuf::new(),
exists: false,
readable: false,
fs_type: None,
skipped: true,
},
nccl: NcclStatus {
library_path: None,
all_found: vec![],
via_docker: Some("cuda".into()),
},
issues: vec![],
warnings: vec!["data-path missing".into()],
};
let j = report_to_json_object(&r);
let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
assert!(v["ready"].as_bool().unwrap());
let warns = v["warnings"].as_array().expect("warnings: []");
assert_eq!(warns.len(), 1);
assert_eq!(v["nccl"]["via_docker"].as_str(), Some("cuda"));
}
#[test]
fn json_survives_control_chars_in_names_and_paths() {
let r = ProbeReport {
host: "h\tost".into(),
gpus: vec![GpuInfo {
index: 0,
vendor: GpuVendor::Nvidia,
name: "Weird\tGPU \"X\"\r\n".into(),
arch: GpuArch::Sm { major: 8, minor: 6 },
total_memory_mb: 1024,
}],
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: vec![],
},
data_path: DataPathStatus {
path: PathBuf::from("/mnt/na\ts"),
exists: true,
readable: true,
fs_type: Some("virtio\u{1}fs".into()),
skipped: false,
},
nccl: NcclStatus {
library_path: None,
all_found: vec![],
via_docker: None,
},
issues: vec!["line1\nline2\ttabbed".into()],
warnings: vec![],
};
let j = report_to_json_object(&r);
let v: serde_json::Value = serde_json::from_str(&j).expect("emit valid JSON");
assert_eq!(v["gpus"][0]["name"].as_str(), Some("Weird\tGPU \"X\"\r\n"));
assert_eq!(v["data_path"]["fs_type"].as_str(), Some("virtio\u{1}fs"));
assert_eq!(v["issues"][0].as_str(), Some("line1\nline2\ttabbed"));
}
#[test]
fn parse_remote_json_flags_schema_skew() {
let worker: ClusterWorker = serde_yaml_ng::from_str(
"host: pascal\nlocal_devices: [0]\nnccl_socket_ifname: lo\npath: /opt/flodl",
)
.expect("minimal worker");
let report =
parse_remote_json(r#"{"something":"else"}"#, &worker).expect("valid JSON parses");
assert!(
report.issues.iter().any(|i| i.contains("version skew")),
"issues: {:?}",
report.issues
);
}
fn wire_test_worker() -> ClusterWorker {
serde_yaml_ng::from_str(
"host: pascal\nlocal_devices: [0]\nnccl_socket_ifname: lo\npath: /opt/flodl",
)
.expect("minimal worker")
}
#[test]
fn gpu_wire_round_trips_both_vendors() {
let r = ProbeReport {
host: "h".into(),
gpus: vec![
GpuInfo {
index: 0,
vendor: GpuVendor::Nvidia,
name: "NVIDIA GeForce RTX 5060 Ti".into(),
arch: GpuArch::Sm {
major: 12,
minor: 0,
},
total_memory_mb: 16311,
},
GpuInfo {
index: 1,
vendor: GpuVendor::Amd,
name: "AMD Radeon RX 6800".into(),
arch: GpuArch::Gfx("gfx1030".into()),
total_memory_mb: 16384,
},
],
libtorch: LibtorchStatus {
info: None,
valid_dir: false,
archs_match: vec![],
},
data_path: DataPathStatus {
path: PathBuf::from("/d"),
exists: true,
readable: true,
fs_type: None,
skipped: false,
},
nccl: NcclStatus {
library_path: None,
all_found: vec![],
via_docker: None,
},
issues: vec![],
warnings: vec![],
};
let back = parse_remote_json(&report_to_json_object(&r), &wire_test_worker())
.expect("emitted JSON parses");
assert_eq!(back.gpus.len(), 2, "warnings: {:?}", back.warnings);
assert_eq!(
back.gpus[0].arch,
GpuArch::Sm {
major: 12,
minor: 0
}
);
assert_eq!(back.gpus[0].vendor, GpuVendor::Nvidia);
assert_eq!(back.gpus[1].arch, GpuArch::Gfx("gfx1030".into()));
assert_eq!(back.gpus[1].vendor, GpuVendor::Amd);
assert_eq!(back.gpus[1].total_memory_mb, 16384);
}
#[test]
fn gpu_wire_reads_a_legacy_sm_only_remote() {
let json =
r#"{"host":"p","gpus":[{"index":0,"name":"A100","sm":"sm_80","vram_mb":81920}]}"#;
let back = parse_remote_json(json, &wire_test_worker()).expect("parses");
assert_eq!(back.gpus.len(), 1);
assert_eq!(back.gpus[0].vendor, GpuVendor::Nvidia);
assert_eq!(back.gpus[0].arch, GpuArch::Sm { major: 8, minor: 0 });
}
#[test]
fn gpu_wire_warns_rather_than_inventing_an_arch() {
let json = r#"{"host":"p","gpus":[{"index":0,"name":"X","vendor":"amd","arch":"wat","vram_mb":8}]}"#;
let back = parse_remote_json(json, &wire_test_worker()).expect("parses");
assert!(back.gpus.is_empty());
assert!(
back.warnings.iter().any(|w| w.contains("unrecognized")),
"warnings: {:?}",
back.warnings
);
}
#[test]
fn fs_type_detected_for_root() {
let t = detect_fs_type(Path::new("/"));
if std::path::Path::new("/proc/mounts").exists() {
assert!(t.is_some(), "expected fs_type for /");
}
}
#[test]
fn mounted_at_answers_only_for_a_real_mount_point() {
if !std::path::Path::new("/proc/mounts").exists() {
return;
}
assert!(mounted_at(Path::new("/")).is_some());
let inside = std::env::temp_dir().join("fdl-not-a-mount-point");
assert!(mounted_at(&inside).is_none());
assert!(detect_fs_type(&inside).is_some(), "but it has an fs type");
}
#[test]
fn mount_fields_come_back_unescaped() {
assert_eq!(unescape_mount("exa:/flodl\\040data"), "exa:/flodl data");
assert_eq!(unescape_mount("plain:/flodl/data"), "plain:/flodl/data");
assert_eq!(unescape_mount("odd\\"), "odd\\");
assert_eq!(unescape_mount("odd\\9x"), "odd\\9x");
}
}