use std::collections::BTreeMap;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use serde::{Deserialize, Serialize};
use crate::cluster::apply_worker_ssh_opts;
use crate::config::{ClusterConfig, ClusterWorker};
pub const ENV_PREBUILD_PER_HOST: &str = "FLODL_INTERNAL_PREBUILD_PER_HOST";
pub fn preflight_hosts(
cluster: &ClusterConfig,
controller_host: &str,
prebuilding: bool,
) -> Result<(), String> {
{
let dp = cluster.controller.effective_data_path().to_string();
let explicit = cluster.controller.data_path.is_some();
let dir_ok = Path::new(&dp).is_dir();
let read_ok = dir_ok && std::fs::read_dir(&dp).is_ok();
if let Some(w) =
check_remote_data_path(controller_host, &dp, explicit, dir_ok, read_ok)?
{
eprintln!("fdl: {w}");
}
}
let remotes: Vec<ClusterWorker> = cluster
.workers
.iter()
.filter(|w| w.host != controller_host)
.cloned()
.collect();
if remotes.is_empty() {
return Ok(());
}
let warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let mut handles = Vec::with_capacity(remotes.len());
for worker in remotes {
let warnings = Arc::clone(&warnings);
let errors = Arc::clone(&errors);
handles.push(thread::spawn(move || {
match preflight_one_host(&worker, prebuilding) {
Ok(ws) => warnings.lock().unwrap().extend(ws),
Err(e) => errors.lock().unwrap().push(e),
}
}));
}
for h in handles {
let _ = h.join();
}
for w in warnings.lock().unwrap().iter() {
eprintln!("fdl: {w}");
}
let errs = Arc::try_unwrap(errors)
.map_err(|_| "internal: preflight error collector still referenced".to_string())?
.into_inner()
.map_err(|e| format!("internal: preflight errors mutex poisoned: {e}"))?;
if !errs.is_empty() {
return Err(format!(
"pre-flight host check failed on {} host(s):\n {}",
errs.len(),
errs.join("\n "),
));
}
Ok(())
}
pub fn prebuild_remotes(
project_root: &Path,
cmd_cwd: &Path,
cluster: &ClusterConfig,
cmd_name: &str,
controller_host: &str,
) -> Result<(), String> {
let remotes: Vec<&ClusterWorker> = cluster
.workers
.iter()
.filter(|w| w.host != controller_host)
.collect();
if remotes.is_empty() {
return Ok(());
}
let controller_docker_svc: Option<String> = cluster.controller.docker.clone();
eprintln!(
"fdl: pre-flight build for {} remote worker(s): {}",
remotes.len(),
remotes.iter().map(|w| w.host.as_str()).collect::<Vec<_>>().join(", "),
);
let controller_path: std::path::PathBuf =
std::path::PathBuf::from(&cluster.controller.path);
let project_root = Arc::new(project_root.to_path_buf());
let cmd_cwd = Arc::new(cmd_cwd.to_path_buf());
let cmd_name = Arc::new(cmd_name.to_string());
let controller_path = Arc::new(controller_path);
let controller_docker_svc = Arc::new(controller_docker_svc);
let errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let envelope: Arc<Mutex<BTreeMap<String, PerHostEnvelope>>> =
Arc::new(Mutex::new(BTreeMap::new()));
{
let first: &ClusterWorker = remotes[0];
if remotes.len() > 1 {
eprintln!(
"fdl: pre-flight warming shared cargo registry via {} (serial) before parallel builds",
first.host,
);
}
match prebuild_one_worker(
&project_root, &cmd_cwd, &controller_path,
first, &cmd_name,
controller_docker_svc.as_deref(),
) {
Ok(env_entry) => {
eprintln!("fdl: pre-flight OK ({})", first.host);
envelope.lock().unwrap().insert(first.host.clone(), env_entry);
}
Err(e) => {
eprintln!("fdl: pre-flight FAILED ({}): {}", first.host, e);
errors.lock().unwrap().push(format!("{}: {}", first.host, e));
}
}
}
let mut handles = Vec::with_capacity(remotes.len().saturating_sub(1));
for worker in &remotes[1..] {
let worker = (*worker).clone();
let project_root = Arc::clone(&project_root);
let cmd_cwd = Arc::clone(&cmd_cwd);
let cmd_name = Arc::clone(&cmd_name);
let controller_path = Arc::clone(&controller_path);
let controller_docker_svc = Arc::clone(&controller_docker_svc);
let errors = Arc::clone(&errors);
let envelope = Arc::clone(&envelope);
handles.push(thread::spawn(move || {
match prebuild_one_worker(
&project_root, &cmd_cwd, &controller_path,
&worker, &cmd_name,
controller_docker_svc.as_deref(),
) {
Ok(env_entry) => {
eprintln!("fdl: pre-flight OK ({})", worker.host);
envelope.lock().unwrap().insert(worker.host.clone(), env_entry);
}
Err(e) => {
eprintln!("fdl: pre-flight FAILED ({}): {}", worker.host, e);
errors.lock().unwrap().push(format!("{}: {}", worker.host, e));
}
}
}));
}
for h in handles {
let _ = h.join();
}
let errs = Arc::try_unwrap(errors)
.map_err(|_| "internal: error collector still has outstanding refs".to_string())?
.into_inner()
.map_err(|e| format!("internal: errors mutex poisoned: {e}"))?;
if !errs.is_empty() {
return Err(format!(
"pre-flight build failed on {} host(s):\n {}",
errs.len(),
errs.join("\n "),
));
}
let env_map = Arc::try_unwrap(envelope)
.map_err(|_| "internal: envelope still has outstanding refs".to_string())?
.into_inner()
.map_err(|e| format!("internal: envelope mutex poisoned: {e}"))?;
let json = serde_json::to_string(&env_map)
.map_err(|e| format!("internal: serialize prebuild envelope: {e}"))?;
unsafe { std::env::set_var(ENV_PREBUILD_PER_HOST, json); }
Ok(())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PerHostEnvelope {
pub bin: String,
pub ld_library_path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub cwd_subpath: String,
}
#[derive(Debug, PartialEq)]
enum AbiCheck {
Ok { warning: Option<String> },
Incompatible(String),
}
fn check_remote_abi(
host: &str,
controller_arch: &str,
remote_uname_m: &str,
remote_ldd: &str,
) -> AbiCheck {
let remote_arch = remote_uname_m.trim();
if remote_arch.is_empty() {
return AbiCheck::Ok {
warning: Some(format!(
"host {host:?}: could not read remote `uname -m`; skipping \
ABI pre-check (a real mismatch would surface at fan-out)"
)),
};
}
if remote_arch != controller_arch {
return AbiCheck::Incompatible(format!(
"host {host:?}: CPU arch mismatch — the pre-built binary is \
{controller_arch} (controller build env) but the remote is \
{remote_arch}. A cross-arch binary cannot exec (`Exec format \
error`). Run same-arch hosts, or build per-arch."
));
}
let ldd_lc = remote_ldd.to_ascii_lowercase();
if ldd_lc.contains("musl") {
return AbiCheck::Incompatible(format!(
"host {host:?}: remote uses musl libc, but the pre-built binary \
is glibc-linked (flodl build images are glibc-based) and cannot \
run there. Match the libc (glibc remote), or build on the remote."
));
}
let looks_glibc = ldd_lc.contains("glibc") || ldd_lc.contains("gnu libc");
let warning = remote_ldd
.lines()
.next()
.map(str::trim)
.filter(|l| !l.is_empty() && looks_glibc)
.map(|l| {
format!(
"host {host:?}: remote glibc reports `{l}`. If the run later \
fails with `GLIBC_… not found`, the remote glibc is older \
than the controller build env — build on the oldest-glibc \
host."
)
});
AbiCheck::Ok { warning }
}
fn check_remote_data_path(
host: &str,
path: &str,
explicit: bool,
dir_ok: bool,
read_ok: bool,
) -> Result<Option<String>, String> {
if !dir_ok {
if explicit {
return Err(format!(
"host {host:?}: shared data path `{path}` does not exist (or is \
not a directory). flodl assumes a shared filesystem (NAS / SMB \
/ virtiofs / SSHFS) mounted at the same logical path on every \
node — training reads data and writes checkpoints there. Mount \
it, or correct `data_path:` in cluster.yml."
));
}
return Ok(Some(format!(
"host {host:?}: convention shared-data path `{path}` not present (no \
`data_path:` declared). Ignore if you don't use shared storage; \
otherwise set `data_path:` per host or mount `{path}`."
)));
}
if !read_ok {
return Err(format!(
"host {host:?}: shared data path `{path}` exists but is not readable \
by the remote user. Check mount permissions / uid mapping."
));
}
Ok(None)
}
fn preflight_one_host(worker: &ClusterWorker, prebuilding: bool) -> Result<Vec<String>, String> {
let target = worker
.ssh
.as_ref()
.and_then(|s| s.target.as_deref())
.unwrap_or(&worker.host);
let dp = worker.effective_data_path().to_string();
let dp_explicit = worker.data_path.is_some();
let mut script = format!(
"if [ -d {q} ]; then echo __FLODL_DP_DIR__=1; else echo __FLODL_DP_DIR__=0; fi; \
if [ -r {q} ]; then echo __FLODL_DP_READ__=1; else echo __FLODL_DP_READ__=0; fi",
q = posix_quote(&dp),
);
if prebuilding {
script.push_str(
"; echo __FLODL_ABI__; uname -m; echo __FLODL_LDD__; \
ldd --version 2>&1 | head -1; echo __FLODL_PKILL__; \
command -v pkill >/dev/null 2>&1 && echo present || echo absent",
);
}
let mut cmd = Command::new("ssh");
apply_worker_ssh_opts(&mut cmd, worker);
cmd.args(["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
cmd.arg(target);
cmd.arg(&script);
let output = match cmd.output() {
Ok(o) => o,
Err(e) => {
return Ok(vec![format!(
"host {:?}: remote pre-check ssh spawn failed ({e}); skipping \
(a real mismatch / missing mount would surface at fan-out)",
worker.host,
)]);
}
};
if !output.status.success() {
return Ok(vec![format!(
"host {:?}: remote pre-check ssh exited {}; skipping (a real \
mismatch / missing mount would surface at fan-out)",
worker.host, output.status,
)]);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut warnings = Vec::new();
let dir_ok = stdout.contains("__FLODL_DP_DIR__=1");
let read_ok = stdout.contains("__FLODL_DP_READ__=1");
if let Some(w) = check_remote_data_path(&worker.host, &dp, dp_explicit, dir_ok, read_ok)? {
warnings.push(w);
}
if prebuilding {
let abi_part = stdout
.split_once("__FLODL_ABI__")
.map(|(_, r)| r)
.unwrap_or("");
let (uname_m, rest) = abi_part
.split_once("__FLODL_LDD__")
.unwrap_or((abi_part, ""));
let (ldd, pkill_field) = rest
.split_once("__FLODL_PKILL__")
.unwrap_or((rest, ""));
let controller_arch = std::env::consts::ARCH;
match check_remote_abi(&worker.host, controller_arch, uname_m, ldd) {
AbiCheck::Ok { warning } => warnings.extend(warning),
AbiCheck::Incompatible(msg) => return Err(msg),
}
if pkill_field.trim() == "absent" {
warnings.push(format!(
"host {:?}: `pkill` not found on the remote — belt-and-braces \
orphan cleanup is disabled; teardown relies solely on the \
per-host trap wrapper (kills by known pid, no external tool). \
Install procps if you want pre-spawn stale-orphan clearing.",
worker.host,
));
}
}
Ok(warnings)
}
fn arch_slug(arch: &str) -> String {
arch.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect()
}
fn prebuild_one_worker(
project_root: &Path,
cmd_cwd: &Path,
controller_path: &Path,
worker: &ClusterWorker,
cmd_name: &str,
controller_docker_svc: Option<&str>,
) -> Result<PerHostEnvelope, String> {
let arch = worker.arch.as_ref().ok_or_else(|| {
format!(
"host {:?} has no `arch:` set in cluster.yml — \
pre-flight build needs the libtorch variant subpath \
(e.g. `arch: precompiled/cu128` or `arch: builds/sm61-sm120`)",
worker.host,
)
})?;
let controller_variant_dir = controller_path.join("libtorch").join(arch);
if !controller_variant_dir.join("lib").is_dir() {
return Err(format!(
"host {:?}: controller-side libtorch at `{}` (resolved from \
`<controller_path>/libtorch/<arch>`) does not look like a \
valid libtorch install (missing `lib/`?)",
worker.host,
controller_variant_dir.display(),
));
}
let host_path = controller_variant_dir.display().to_string();
let (features_arg, _) = features_and_service_from_arch(arch);
let cuda_version_for_image = cuda_version_from_arch(arch);
let target_dir_relative =
format!("target/cluster/{}/{}", worker.host, arch_slug(arch));
let (sh_cmd, cwd_for_spawn, extra_envs): (String, &Path, Vec<(&str, String)>) =
if let Some(docker_svc) = controller_docker_svc {
let target_dir_in_container = format!("/workspace/{target_dir_relative}");
let sub_path = cmd_cwd
.strip_prefix(project_root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let cwd_in_container = if sub_path.is_empty() {
"/workspace".to_string()
} else {
format!("/workspace/{sub_path}")
};
let build_cmd = if features_arg.is_empty() {
format!(
"cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --bin {bin}",
cwd = posix_quote(&cwd_in_container),
tgt = posix_quote(&target_dir_in_container),
bin = posix_quote(cmd_name),
)
} else {
format!(
"cd {cwd} && CARGO_TARGET_DIR={tgt} cargo build --release --features {feat} --bin {bin}",
cwd = posix_quote(&cwd_in_container),
tgt = posix_quote(&target_dir_in_container),
feat = posix_quote(features_arg),
bin = posix_quote(cmd_name),
)
};
let docker_cmd = format!(
"docker compose run --rm {svc} bash -c {inner}",
svc = docker_svc,
inner = posix_quote(&build_cmd),
);
(docker_cmd, project_root, vec![
("LIBTORCH_HOST_PATH", host_path.clone()),
("LIBTORCH_CPU_PATH", "./libtorch/precompiled/cpu".into()),
])
} else {
let target_dir_abs = project_root.join(&target_dir_relative);
let bash_cmd = if features_arg.is_empty() {
format!(
"cargo build --release --bin {bin}",
bin = posix_quote(cmd_name),
)
} else {
format!(
"cargo build --release --features {feat} --bin {bin}",
feat = posix_quote(features_arg),
bin = posix_quote(cmd_name),
)
};
(bash_cmd, cmd_cwd, vec![
("LIBTORCH_PATH", host_path.clone()),
(
"CARGO_TARGET_DIR",
target_dir_abs.to_string_lossy().into_owned(),
),
])
};
let mut cmd = Command::new("sh");
cmd.args(["-c", &sh_cmd])
.current_dir(cwd_for_spawn)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.stdin(Stdio::null());
for (k, v) in &extra_envs {
cmd.env(k, v);
}
if let Some(cuda_version) = &cuda_version_for_image {
let normalised = if cuda_version.matches('.').count() < 2 {
format!("{cuda_version}.0")
} else {
cuda_version.clone()
};
let cuda_tag = normalised
.splitn(3, '.')
.take(2)
.collect::<Vec<_>>()
.join(".");
cmd.env("CUDA_VERSION", &normalised);
cmd.env("CUDA_TAG", &cuda_tag);
}
let status = cmd
.status()
.map_err(|e| format!("spawn `{sh_cmd}`: {e}"))?;
if !status.success() {
return Err(format!(
"cargo build exited {} (libtorch={host_path}, target={target_dir_relative}, \
features={feat})",
status.code().unwrap_or(-1),
feat = if features_arg.is_empty() { "(none)" } else { features_arg },
));
}
let runtime_lib = format!(
"{path}/libtorch/{arch}/lib",
path = worker.path.trim_end_matches('/'),
);
let _ = host_path; let cwd_subpath = cmd_cwd
.strip_prefix(project_root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(PerHostEnvelope {
bin: format!("{target_dir_relative}/release/{cmd_name}"),
ld_library_path: runtime_lib,
cwd_subpath,
})
}
fn features_and_service_from_arch(arch: &str) -> (&'static str, &'static str) {
let basename = std::path::Path::new(arch)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if basename == "cpu" {
("", "dev")
} else {
("cuda", "cuda")
}
}
fn cuda_version_from_arch(arch: &str) -> Option<String> {
let basename = std::path::Path::new(arch)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let rest = basename.strip_prefix("cu")?;
if rest.len() < 2 || !rest.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let major = &rest[..rest.len() - 1];
let minor = &rest[rest.len() - 1..];
Some(format!("{major}.{minor}"))
}
use crate::util::shell::posix_quote;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abi_arch_mismatch_is_hard_incompatible() {
let r = check_remote_abi("gv", "x86_64", "aarch64", "ldd (GNU libc) 2.31");
assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
}
#[test]
fn data_path_present_and_readable_is_clean() {
assert!(check_remote_data_path("h", "/flodl/data", true, true, true)
.expect("ok")
.is_none());
}
#[test]
fn data_path_missing_explicit_is_hard_error() {
let err = check_remote_data_path("h", "/mnt/nas", true, false, false)
.expect_err("explicit missing must be a hard error");
assert!(err.contains("does not exist"), "err: {err}");
assert!(err.contains("/mnt/nas"), "err: {err}");
}
#[test]
fn data_path_missing_convention_default_is_warning() {
let w = check_remote_data_path("h", "/flodl/data", false, false, false)
.expect("convention-default missing must be Ok(warning)")
.expect("should carry a warning");
assert!(w.contains("convention"), "warn: {w}");
}
#[test]
fn data_path_present_but_unreadable_is_hard_error() {
let err = check_remote_data_path("h", "/flodl/data", false, true, false)
.expect_err("present-but-unreadable must be a hard error regardless of explicit");
assert!(err.contains("not readable"), "err: {err}");
}
#[test]
fn abi_musl_is_hard_incompatible_on_matching_arch() {
let r = check_remote_abi("alp", "x86_64", "x86_64", "musl libc (x86_64)\nVersion 1.2.4");
assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("musl")));
}
#[test]
fn abi_matching_arch_glibc_ok_with_version_note() {
let r = check_remote_abi(
"w", "x86_64", "x86_64",
"ldd (Ubuntu GLIBC 2.35-0ubuntu3.4) 2.35",
);
match r {
AbiCheck::Ok { warning: Some(w) } => {
assert!(w.contains("2.35"), "warning should quote the reported line: {w}");
}
other => panic!("expected Ok+warning, got {other:?}"),
}
}
#[test]
fn abi_empty_uname_is_indeterminate_not_mismatch() {
let r = check_remote_abi("w", "x86_64", "", "");
assert!(matches!(r, AbiCheck::Ok { warning: Some(_) }));
}
#[test]
fn abi_arch_checked_before_musl() {
let r = check_remote_abi("x", "x86_64", "aarch64", "musl libc");
assert!(matches!(r, AbiCheck::Incompatible(m) if m.contains("arch mismatch")));
}
#[test]
fn abi_matching_arch_no_ldd_ok_no_warning() {
let r = check_remote_abi("w", "x86_64", "x86_64", "");
assert_eq!(r, AbiCheck::Ok { warning: None });
}
#[test]
fn features_and_service_precompiled_cuda_picks_cuda() {
assert_eq!(
features_and_service_from_arch("precompiled/cu128"),
("cuda", "cuda")
);
}
#[test]
fn features_and_service_precompiled_cpu_picks_dev() {
assert_eq!(
features_and_service_from_arch("precompiled/cpu"),
("", "dev")
);
}
#[test]
fn features_and_service_source_build_picks_cuda() {
assert_eq!(
features_and_service_from_arch("builds/sm61-sm120"),
("cuda", "cuda")
);
assert_eq!(
features_and_service_from_arch("builds/sm80"),
("cuda", "cuda")
);
}
#[test]
fn cuda_version_from_arch_extracts_precompiled_version() {
assert_eq!(cuda_version_from_arch("precompiled/cu128"), Some("12.8".into()));
assert_eq!(cuda_version_from_arch("precompiled/cu126"), Some("12.6".into()));
assert_eq!(cuda_version_from_arch("precompiled/cu118"), Some("11.8".into()));
}
#[test]
fn cuda_version_from_arch_none_for_source_builds_and_cpu() {
assert_eq!(cuda_version_from_arch("builds/sm61-sm120"), None);
assert_eq!(cuda_version_from_arch("builds/sm80"), None);
assert_eq!(cuda_version_from_arch("precompiled/cpu"), None);
}
#[test]
fn arch_slug_folds_path_separators_only() {
assert_eq!(arch_slug("precompiled/cu128"), "precompiled-cu128");
assert_eq!(arch_slug("precompiled/cu118"), "precompiled-cu118");
assert_ne!(arch_slug("precompiled/cu128"), arch_slug("precompiled/cu118"));
assert_eq!(arch_slug("builds/sm61-sm120"), "builds-sm61-sm120");
assert_eq!(arch_slug("cpu"), "cpu");
}
#[test]
fn posix_quote_round_trips_safe_strings() {
assert_eq!(posix_quote("ddp-bench"), "ddp-bench");
assert_eq!(posix_quote("target/cluster/exa"), "target/cluster/exa");
assert_eq!(posix_quote(""), "''");
}
#[test]
fn posix_quote_wraps_unsafe_strings() {
assert_eq!(posix_quote("a b"), "'a b'");
assert_eq!(posix_quote("it's"), "'it'\\''s'");
}
#[test]
fn envelope_serializes_to_stable_json() {
let mut env = BTreeMap::new();
env.insert(
"host-b".to_string(),
PerHostEnvelope {
bin: "target/cluster/host-b/release/bench".into(),
ld_library_path: "/opt/lt-b/lib".into(),
cwd_subpath: String::new(),
},
);
env.insert(
"host-a".to_string(),
PerHostEnvelope {
bin: "target/cluster/host-a/release/bench".into(),
ld_library_path: "/opt/lt-a/lib".into(),
cwd_subpath: String::new(),
},
);
let json = serde_json::to_string(&env).unwrap();
assert_eq!(
json,
r#"{"host-a":{"bin":"target/cluster/host-a/release/bench","ld_library_path":"/opt/lt-a/lib"},"host-b":{"bin":"target/cluster/host-b/release/bench","ld_library_path":"/opt/lt-b/lib"}}"#,
);
}
#[test]
fn envelope_round_trips_through_serde() {
let mut env = BTreeMap::new();
env.insert(
"h1".to_string(),
PerHostEnvelope {
bin: "t/c/h1/release/x".into(),
ld_library_path: "/opt/lt/lib".into(),
cwd_subpath: "ddp-bench".into(),
},
);
let json = serde_json::to_string(&env).unwrap();
let back: BTreeMap<String, PerHostEnvelope> =
serde_json::from_str(&json).unwrap();
assert_eq!(back.len(), 1);
let e = back.get("h1").unwrap();
assert_eq!(e.bin, "t/c/h1/release/x");
assert_eq!(e.ld_library_path, "/opt/lt/lib");
}
}