flodl_cli/cluster.rs
1//! Cluster-mode env preparation.
2//!
3//! Process-per-rank model: flodl owns fan-out and controller
4//! orchestration (`flodl::distributed::launcher::run_launcher`). fdl-cli's
5//! job here is purely to ship the parsed cluster topology to the launcher
6//! via env vars, then let the normal `RunScript` / `ExecCommand` dispatch
7//! invoke the user binary. The user binary's
8//! `flodl::distributed::launcher::dispatch` reads the env, detects
9//! launcher role, and fans out (ssh for remote hosts, fork+exec for local
10//! hosts). All log fan-in + ClusterController + exit-code propagation happen on
11//! the flodl side.
12//!
13//! ```text
14//! fdl @cluster train
15//! ↓ fdl-cli parses fdl.yml + fdl.cluster.yml overlay
16//! ↓ fdl-cli calls prepare_cluster_env: sets FLODL_INTERNAL_FULL_CLUSTER_JSON,
17//! FLODL_INTERNAL_FDL_CMD, FDL_ENV on its own process env
18//! ↓ fdl-cli falls through to normal RunScript / ExecCommand path
19//! ↓ resolved command (e.g. `cargo run --release --bin my-trainer`) runs
20//! ↓ my-trainer inherits env, flodl::launcher::dispatch detects Launcher
21//! ↓ launcher fans out: ssh per remote host, fork+exec per local rank
22//! ↓ each rank child has FLODL_INTERNAL_CLUSTER_JSON + FLODL_INTERNAL_LOCAL_RANK set
23//! ↓ rank-side flodl::launcher::dispatch returns Role::Rank, training runs
24//! ```
25//!
26//! Recursion guard: the launcher's ssh fan-out invokes `fdl <cmd>` on the
27//! remote, which re-enters fdl-cli with `FLODL_INTERNAL_CLUSTER_JSON` set (not
28//! `FLODL_INTERNAL_FULL_CLUSTER_JSON`). `should_dispatch`
29//! returns `false` in that case so the remote fdl-cli skips cluster setup
30//! and just runs the user binary normally — the user binary's launcher
31//! dispatch then detects `Role::Rank` (because `FLODL_INTERNAL_LOCAL_RANK` is also
32//! set).
33
34use std::path::Path;
35use std::process::Command;
36
37use crate::config::{self, ClusterConfig, ProjectConfig};
38
39/// Env var name carrying the *full* multi-host topology (hex-encoded
40/// JSON of [`ClusterConfig`]). Set by fdl-cli on its own process env so
41/// the spawned user binary inherits it and detects launcher role.
42/// Mirrors `flodl::distributed::launcher::ENV_FULL_CLUSTER_JSON`.
43pub const ENV_FULL_CLUSTER_JSON: &str = "FLODL_INTERNAL_FULL_CLUSTER_JSON";
44
45/// Env var name carrying the original fdl command name (e.g. `train`).
46/// Read by the launcher when it needs to invoke `fdl <cmd>` over ssh
47/// on remote hosts. Mirrors `flodl::distributed::launcher::ENV_FDL_CMD`.
48pub const ENV_FDL_CMD: &str = "FLODL_INTERNAL_FDL_CMD";
49
50/// Env var name picking the overlay env name (e.g. `cluster`). Set by
51/// fdl-cli at env-selector parsing time; propagated through to remote
52/// hosts by the launcher so they see the same overlay-merged view.
53pub const ENV_FDL_ENV: &str = "FDL_ENV";
54
55/// Env var name carrying the slim per-rank envelope. Set by the
56/// launcher (not fdl-cli) on each rank child. Kept here so the
57/// recursion guard can reference it by name. Mirrors
58/// `flodl::distributed::cluster::ENV_CLUSTER_JSON`.
59pub const ENV_CLUSTER_JSON: &str = "FLODL_INTERNAL_CLUSTER_JSON";
60
61/// Pre-resolved `name:ip` pairs (space-separated) for every cluster
62/// host, written by [`prepare_cluster_env`] using the controller's NSS
63/// resolution. Consumed by run/prebuild/schema-cache when they build
64/// `docker compose run --rm` commands: each pair is injected as a
65/// `--add-host name:ip` flag so the containerized launcher can SSH
66/// into cluster hosts without depending on the container's own
67/// resolver (which lacks `libnss-libvirt` etc.).
68pub const ENV_CLUSTER_EXTRA_HOSTS: &str = "FLODL_INTERNAL_CLUSTER_EXTRA_HOSTS";
69
70/// Controller's OS user name (resolved on the host by fdl-cli, before
71/// any docker spawn). The launcher in the container reads it as the
72/// default `ssh -l` target when the per-host `ssh.user:` is unset.
73/// Bridges the container-vs-host user mismatch (containers ship a
74/// stock `ubuntu` UID-1000 user, but `ubuntu@<remote>` is rarely the
75/// account the user actually uses on cluster hosts).
76pub const ENV_HOST_USER: &str = "FLODL_INTERNAL_HOST_USER";
77
78/// Env var name overriding the OS hostname for cluster lookups.
79/// Mirrors `flodl::distributed::cluster::ENV_HOST_OVERRIDE`.
80pub const ENV_HOST_OVERRIDE: &str = "FLODL_HOST_NAME";
81
82/// Env var name picking this rank's local-rank index within its host.
83/// Set by the launcher on rank children. Mirrors
84/// `flodl::distributed::cluster::ENV_LOCAL_RANK`.
85pub const ENV_LOCAL_RANK: &str = "FLODL_INTERNAL_LOCAL_RANK";
86
87/// True if `key` must not appear in a user-supplied cluster/host `env`
88/// map. Mirrors `flodl::distributed::cluster::is_reserved_cluster_env_key`
89/// (kept in lockstep — flodl-cli is decoupled from the library crate, so
90/// the reserved rule is duplicated, not imported). The launcher applies
91/// user env after its own built-ins (shell last-wins), so a launcher-
92/// owned key set via env would silently override device mapping / rank
93/// identity / the HMAC envelope. Reserved: the loud `FLODL_INTERNAL_`
94/// prefix (all launcher-private vars, future-proof) plus three names that
95/// are user-facing elsewhere but launcher-owned per-rank here
96/// (`CUDA_VISIBLE_DEVICES` + `CUDA_DEVICE_ORDER`, [`ENV_HOST_OVERRIDE`],
97/// [`ENV_FDL_ENV`]).
98/// User-facing knobs (`FLODL_VERBOSITY`, `FLODL_DASHBOARD_BIND`, NCCL
99/// tuning, `LD_LIBRARY_PATH`) are deliberately allowed.
100pub fn is_reserved_cluster_env_key(key: &str) -> bool {
101 key.starts_with("FLODL_INTERNAL_")
102 || key == "CUDA_VISIBLE_DEVICES"
103 || key == "CUDA_DEVICE_ORDER"
104 || key == ENV_HOST_OVERRIDE
105 || key == ENV_FDL_ENV
106}
107
108/// Env var scaling every flodl cluster network deadline together
109/// (connect budget, write-stall, heartbeat staleness, coord-liveness,
110/// CPU reduce read). Mirrors `flodl::distributed::wire`'s constant +
111/// validation rule (kept in lockstep — flodl-cli is decoupled from the
112/// library crate). The library reader warns-and-defaults on a bad
113/// value; the cluster fan-out path calls
114/// [`validate_net_timeout_scale`] first so an explicit-but-invalid
115/// value errors loudly BEFORE any host is touched.
116pub const ENV_NET_TIMEOUT_SCALE: &str = "FLODL_NET_TIMEOUT_SCALE";
117
118/// Validate `FLODL_NET_TIMEOUT_SCALE` from the process env: unset is
119/// fine (scale 1.0); a set value must parse as a finite float ≥ 0.1.
120/// Mirrors the library's parse rule.
121pub fn validate_net_timeout_scale() -> Result<(), String> {
122 validate_net_timeout_scale_value(std::env::var(ENV_NET_TIMEOUT_SCALE).ok().as_deref())
123}
124
125/// Pure core of [`validate_net_timeout_scale`] (unit-testable without
126/// env mutation).
127fn validate_net_timeout_scale_value(raw: Option<&str>) -> Result<(), String> {
128 let Some(raw) = raw else { return Ok(()) };
129 let trimmed = raw.trim();
130 match trimmed.parse::<f64>() {
131 Ok(v) if v.is_finite() && v >= 0.1 => Ok(()),
132 Ok(_) => Err(format!(
133 "{ENV_NET_TIMEOUT_SCALE}={trimmed} is out of range; expected a \
134 finite scale factor >= 0.1 (0.1 keeps every deadline above the \
135 1s heartbeat cadence)"
136 )),
137 Err(_) => Err(format!(
138 "{ENV_NET_TIMEOUT_SCALE}={trimmed:?} is not a number; expected a \
139 scale factor >= 0.1 (e.g. 3 for a slow WAN link, 0.5 for a \
140 fast-failure test rig)"
141 )),
142 }
143}
144
145/// Top-level cluster-dispatch decision.
146///
147/// Returns `false` when `FLODL_INTERNAL_CLUSTER_JSON` is set — that signals we're
148/// a recursive fdl invocation on a remote host that the launcher's ssh
149/// fan-out reached, and we should fall through to normal dispatch.
150/// Otherwise delegates to [`config::cluster_dispatch_enabled`].
151pub fn should_dispatch(project: &ProjectConfig, chain: &[Option<bool>]) -> bool {
152 if is_recursive_invocation() {
153 return false;
154 }
155 config::cluster_dispatch_enabled(project, chain)
156}
157
158/// Whether this fdl invocation is itself a spawned child of a launcher's
159/// ssh fan-out (`FLODL_INTERNAL_CLUSTER_JSON` already set in env). Used as the
160/// recursion guard everywhere cluster dispatch is evaluated.
161pub fn is_recursive_invocation() -> bool {
162 std::env::var_os(ENV_CLUSTER_JSON).is_some()
163}
164
165/// Prepare the env vars needed for the user binary's flodl launcher to
166/// detect launcher role and fan out. Caller continues to normal
167/// dispatch (`RunScript` / `ExecCommand`); the spawned subprocess
168/// inherits these env vars and the launcher takes over.
169///
170/// `overlay_env` is the overlay name from `fdl @<env>` (e.g.
171/// `Some("cluster")`); propagated to remote hosts via the launcher so
172/// they see the same overlay-merged `commands:` resolution.
173///
174/// Returns `Err` if the cluster config is invalid or JSON serialization
175/// fails — surfaces the error before the user binary even starts.
176///
177/// On success returns a `Vec<String>` of non-fatal resolution warnings
178/// (one entry per host whose NSS lookup failed or yielded only loopback
179/// addresses). The cluster-dispatch site in `main.rs` is the one that
180/// chooses to print them. Tests that exercise this function for its
181/// env-setting behavior simply ignore the returned Vec.
182pub fn prepare_cluster_env(
183 cluster: &ClusterConfig,
184 overlay_env: Option<&str>,
185 cmd: &str,
186) -> Result<Vec<String>, String> {
187 cluster.validate()?;
188 let mut warnings: Vec<String> = Vec::new();
189 // Pre-resolve `controller.host` on the controller (where NSS knows
190 // names declared in `/etc/hosts`, `libnss-libvirt`, mDNS, etc.)
191 // and ship the resolved IP in the envelope to remote ranks. Remote
192 // VMs that don't share the controller's NSS view (a Pascal VM on
193 // libvirt's virbr0 has no plugin to resolve "exa") then connect
194 // by numeric IP without needing their own resolver to know cluster
195 // hostnames. If resolution fails on the controller, ship the
196 // original string and let the remote try its own NSS as a last
197 // resort.
198 let mut shippable = cluster.clone();
199 let (controller_ip, controller_warning) =
200 resolve_host_to_ip(&shippable.controller.host);
201 if let Some(ip) = controller_ip {
202 shippable.controller.host = ip;
203 }
204 if let Some(w) = controller_warning {
205 warnings.push(w);
206 }
207 // Probe device counts per worker, then populate global ranks by
208 // sequential assignment. `ranks` is not user-facing in the YAML
209 // schema (see `ClusterWorker::ranks` — `skip_deserializing`); the
210 // probe result is authoritative. Probing only SSHes for workers
211 // that use `local_devices: all`; explicit lists carry their own
212 // count. After populate_ranks the cluster shape on the wire is
213 // identical to the legacy explicit form.
214 let counts = probe_worker_device_counts(&shippable)?;
215 shippable.populate_ranks(&counts)?;
216 shippable.validate()?;
217 let json = shippable.canonical_json()?;
218 let hex = hex_encode(json.as_bytes());
219 let (extra_hosts, host_warnings) = resolve_cluster_extra_hosts(cluster);
220 warnings.extend(host_warnings);
221
222 // Controller user for the launcher's default `ssh -l`. On the rare
223 // double-failure (no USER, no whoami) leave the env UNSET — the
224 // launcher then omits `-l` and ssh applies its own defaults — and
225 // warn, instead of shipping a fabricated username into ssh auth.
226 let host_user = resolve_local_user();
227 if host_user.is_none() {
228 warnings.push(
229 "could not determine the controller's user (USER unset, whoami \
230 unavailable); ssh will use its own defaults — set `ssh.user:` \
231 per host in fdl.cluster.yml if remote accounts differ"
232 .to_string(),
233 );
234 }
235 // SAFETY: main() has not spawned threads at this point in the
236 // dispatch flow (mirrors gpus::apply_cuda_visible_devices's
237 // invariant; documented in main.rs).
238 unsafe {
239 std::env::set_var(ENV_FULL_CLUSTER_JSON, &hex);
240 std::env::set_var(ENV_FDL_CMD, cmd);
241 if let Some(u) = &host_user {
242 std::env::set_var(ENV_HOST_USER, u);
243 }
244 if !extra_hosts.is_empty() {
245 std::env::set_var(ENV_CLUSTER_EXTRA_HOSTS, extra_hosts.join(" "));
246 }
247 if let Some(e) = overlay_env {
248 if !e.trim().is_empty() {
249 std::env::set_var(ENV_FDL_ENV, e);
250 }
251 }
252 }
253 Ok(warnings)
254}
255
256/// Local-only sibling of [`probe_worker_device_counts`], used by the
257/// testing-envelope export path in [`prepare_test_cluster_env`].
258///
259/// Testing-mode cluster invocations (`fdl @cluster-test <cmd>`) run the
260/// test binary in-process on one host; there's no SSH fan-out, so any
261/// worker declaring `local_devices: all` is by definition referring to
262/// the local machine's visible GPUs. Use `nvidia-smi -L` locally
263/// instead of SSHing back to ourselves.
264///
265/// - `LocalDevices::Explicit(v)` → `v.len()`
266/// - `LocalDevices::All` → [`crate::gpus::count_visible_gpus_via_nvidia_smi`]
267/// (result cached across workers since they all resolve to the same
268/// local box in testing mode).
269///
270/// Errors loudly on nvidia-smi failure or a 0 count (caller treats 0
271/// as misconfiguration — no GPUs visible to the test).
272fn probe_local_device_counts(cluster: &ClusterConfig) -> Result<Vec<usize>, String> {
273 let mut counts = Vec::with_capacity(cluster.workers.len());
274 let mut cached_local: Option<usize> = None;
275 for (i, w) in cluster.workers.iter().enumerate() {
276 let count = match &w.local_devices {
277 config::LocalDevices::Explicit(v) => v.len(),
278 config::LocalDevices::All => {
279 if cached_local.is_none() {
280 cached_local = Some(
281 crate::gpus::count_visible_gpus_via_nvidia_smi().map_err(|e| {
282 format!(
283 "cluster.workers[{i}] ({:?}): local nvidia-smi \
284 probe failed: {e}",
285 w.host,
286 )
287 })?,
288 );
289 }
290 cached_local.unwrap()
291 }
292 };
293 if count == 0 {
294 return Err(format!(
295 "cluster.workers[{i}] ({:?}): 0 CUDA devices visible \
296 (local_devices: all). Run `fdl @cluster-test <cmd>` on a \
297 host with visible GPUs, or use an explicit \
298 `local_devices: [...]` list.",
299 w.host,
300 ));
301 }
302 counts.push(count);
303 }
304 Ok(counts)
305}
306
307/// Prepare the testing-cluster envelope (`FLODL_TESTING_CLUSTER_JSON`).
308///
309/// Mirrors [`prepare_cluster_env`] for the testing path: clones the
310/// cluster, probes device counts LOCALLY (no SSH — tests run
311/// in-process on the local host), populates ranks by sequential
312/// assignment, then serializes for shipping.
313///
314/// Returns the hex-encoded envelope ready to set in the env. Errors
315/// surface from `cluster.validate()` (structural), the local probe,
316/// or `populate_ranks` (count/worker mismatch).
317pub fn prepare_test_cluster_env(cluster: &ClusterConfig) -> Result<String, String> {
318 cluster.validate()?;
319 let mut shippable = cluster.clone();
320 let counts = probe_local_device_counts(&shippable)?;
321 shippable.populate_ranks(&counts)?;
322 shippable.validate()?;
323 let json = shippable.canonical_json()?;
324 Ok(hex_encode(json.as_bytes()))
325}
326
327/// Probe each worker's CUDA device count.
328///
329/// - `LocalDevices::Explicit(v)` → `v.len()` (no SSH, no remote call)
330/// - `LocalDevices::All` → SSH to the worker and run
331/// `nvidia-smi --query-gpu=index --format=csv,noheader 2>/dev/null | wc -l`,
332/// parse as `usize`. Errors loudly on SSH failure, parse failure, or
333/// a 0 count (caller treats 0 as misconfiguration).
334///
335/// Returns one count per worker, in worker-declaration order. Used by
336/// [`prepare_cluster_env`] before envelope emission so global rank
337/// assignment can be sequential without requiring users to enumerate
338/// `ranks:` in YAML.
339fn probe_worker_device_counts(cluster: &ClusterConfig) -> Result<Vec<usize>, String> {
340 let mut counts = Vec::with_capacity(cluster.workers.len());
341 for (i, w) in cluster.workers.iter().enumerate() {
342 let count = match &w.local_devices {
343 config::LocalDevices::Explicit(v) => v.len(),
344 config::LocalDevices::All => ssh_query_gpu_count(w).map_err(|e| {
345 format!(
346 "cluster.workers[{i}] ({:?}): probe failed: {e}",
347 w.host,
348 )
349 })?,
350 };
351 if count == 0 {
352 return Err(format!(
353 "cluster.workers[{i}] ({:?}): probed 0 CUDA devices \
354 (local_devices: all). Either the host has no GPUs visible \
355 (check nvidia-smi + CUDA_VISIBLE_DEVICES) or it's a \
356 misconfiguration — provide an explicit `local_devices: [...]` \
357 list instead.",
358 w.host,
359 ));
360 }
361 counts.push(count);
362 }
363 Ok(counts)
364}
365
366/// SSH to a worker and count visible CUDA devices via `nvidia-smi`.
367///
368/// Honors the worker's `ssh:` sub-block (target / port / user /
369/// identity_file / options). Falls back to `host` as the ssh target
370/// when no `ssh:` block is provided; system ssh resolves the rest via
371/// `~/.ssh/config`.
372///
373/// Times out after 5s on connect to keep cluster startup snappy when a
374/// host is unreachable. Uses `BatchMode=yes` so a passphrase prompt
375/// doesn't hang the dispatch.
376/// Apply a worker's `ssh:` sub-block (port / user / identity_file /
377/// options) as flags on an `ssh` `Command`.
378///
379/// Call this BEFORE pushing flodl's default connect-behavior flags (`-T`,
380/// `BatchMode`, timeouts), then the target host + remote command. User
381/// `ssh.options` are emitted here first so they take precedence: OpenSSH uses
382/// the first value seen for each `-o`, so flodl's later defaults fill in only
383/// the options the user didn't set (M17). The one option flodl truly needs —
384/// `BatchMode=yes`, so its non-interactive ssh never hangs on a prompt — is
385/// still overridable, but [`batchmode_override_warning`] flags it loudly.
386///
387/// Shared by cluster GPU-count dispatch ([`ssh_query_gpu_count`]) and
388/// `fdl probe`'s remote fan-out (`probe::probe_remote_via_ssh`) so both
389/// reach a host the same way — notably a Docker-container rank exposed
390/// on `127.0.0.1:<port>` with an `identity_file` (without these flags
391/// ssh defaults to port 22 / the login user and the connect is
392/// refused). The `target` itself is set by the caller (it falls back to
393/// `worker.host` when no `ssh.target` is declared).
394pub(crate) fn apply_worker_ssh_opts(cmd: &mut Command, worker: &config::ClusterWorker) {
395 if let Some(ssh) = worker.ssh.as_ref() {
396 if let Some(port) = ssh.port {
397 cmd.arg("-p").arg(port.to_string());
398 }
399 if let Some(user) = ssh.user.as_deref() {
400 cmd.arg("-l").arg(user);
401 }
402 if let Some(id) = ssh.identity_file.as_deref() {
403 cmd.arg("-i").arg(id);
404 }
405 if let Some(warning) = batchmode_override_warning(&ssh.options, &worker.host) {
406 eprintln!("{warning}");
407 }
408 for opt in &ssh.options {
409 cmd.arg("-o").arg(opt);
410 }
411 }
412}
413
414/// The warning message when a worker's `ssh.options` set `BatchMode` to a
415/// non-`yes` value, else `None`. flodl's remote ssh (dispatch + probes) is
416/// non-interactive, so a prompt hangs it; `BatchMode=yes` is the one truly
417/// required ssh option. Every other flodl default is freely overridable (M17).
418fn batchmode_override_warning(opts: &[String], host: &str) -> Option<String> {
419 opts.iter().find_map(|opt| {
420 let (k, v) = opt.split_once('=')?;
421 (k.trim().eq_ignore_ascii_case("BatchMode")
422 && !v.trim().eq_ignore_ascii_case("yes"))
423 .then(|| {
424 format!(
425 "fdl: host {host:?} ssh.options set `{}` — flodl's ssh is \
426 non-interactive and will hang on any prompt (passphrase, \
427 host-key). Proceeding as requested.",
428 opt.trim()
429 )
430 })
431 })
432}
433
434fn ssh_query_gpu_count(worker: &config::ClusterWorker) -> Result<usize, String> {
435 let target = worker
436 .ssh
437 .as_ref()
438 .and_then(|s| s.target.as_deref())
439 .unwrap_or(&worker.host);
440 let mut cmd = Command::new("ssh");
441 // User ssh.options first (they win), then flodl's defaults (M17).
442 apply_worker_ssh_opts(&mut cmd, worker);
443 cmd.args([
444 "-T",
445 "-o",
446 "BatchMode=yes",
447 "-o",
448 "ConnectTimeout=5",
449 ]);
450 cmd.arg(target);
451 // Pipe to wc -l for a one-line numeric output; nvidia-smi's
452 // error stream is silenced so a missing driver produces "0" cleanly
453 // rather than a parse failure on stderr noise.
454 cmd.arg("nvidia-smi --query-gpu=index --format=csv,noheader 2>/dev/null | wc -l");
455
456 let output = cmd
457 .output()
458 .map_err(|e| format!("ssh spawn failed: {e}"))?;
459 if !output.status.success() {
460 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
461 return Err(format!(
462 "ssh to {target:?} exited {} (stderr: {stderr})",
463 output.status,
464 ));
465 }
466 let stdout = String::from_utf8_lossy(&output.stdout);
467 let count_str = stdout.trim();
468 count_str.parse::<usize>().map_err(|e| {
469 format!(
470 "could not parse nvidia-smi output as device count: {e:?} \
471 (got {count_str:?})"
472 )
473 })
474}
475
476/// Resolve each cluster worker's `host` to an IP via the controller's
477/// NSS (which on Linux includes static `/etc/hosts`, `libnss-libvirt`,
478/// `libnss-mdns`, and DNS — anything `getaddrinfo` knows about).
479/// Returns `(Vec<"host:ip">, Vec<warning>)`: the first list is suitable
480/// for `--add-host` injection into `docker compose run`; the second is
481/// human-readable warnings the cluster-dispatch site can surface to the
482/// user. Workers that fail to resolve are skipped from the `host:ip`
483/// list (better-than-nothing semantics for the launcher inside the
484/// container — the unresolved host will retry via its own NSS).
485fn resolve_cluster_extra_hosts(cluster: &ClusterConfig) -> (Vec<String>, Vec<String>) {
486 let mut hosts = Vec::new();
487 let mut warnings = Vec::new();
488 for w in &cluster.workers {
489 let (ip, warning) = resolve_host_to_ip(&w.host);
490 if let Some(ip) = ip {
491 hosts.push(format!("{}:{ip}", w.host));
492 }
493 // Suppress the warning when the worker carries an explicit
494 // `ssh.target`. The `host:` value is then just a label — the
495 // actual connection uses ssh.target (or `host:` only as the
496 // default ssh target, which is itself a system-ssh lookup, not
497 // a process-controller-side NSS lookup). Workers reached via
498 // ~/.ssh/config aliases (e.g. a `node-b` alias mapping to
499 // 127.0.0.1:2222) routinely don't resolve via host NSS, and
500 // surfacing the warning every run is noise.
501 let has_explicit_ssh_target = w
502 .ssh
503 .as_ref()
504 .and_then(|s| s.target.as_deref())
505 .is_some();
506 if let Some(msg) = warning
507 && !has_explicit_ssh_target
508 {
509 warnings.push(msg);
510 }
511 }
512 (hosts, warnings)
513}
514
515/// Resolve a hostname to an IP string via `getaddrinfo`. Returns
516/// `(Option<ip>, Option<warning>)` — both are independently optional so
517/// the caller can ship the resolved IP AND surface the warning, or
518/// either alone. The function itself is silent; warnings are returned
519/// for the cluster-dispatch site to emit (or ignore in non-dispatch
520/// contexts like unit tests that exercise the env-setting paths).
521///
522/// Prefers a non-loopback address when `getaddrinfo` returns several
523/// candidates. Debian/Ubuntu install `/etc/hosts` with a
524/// `127.0.1.1 <hostname>` line by default, which `getaddrinfo` returns
525/// FIRST — that IP works for the local host but is unreachable from
526/// any peer (a libvirt VM, another rig). Skipping loopback in the
527/// iterator picks the bridge / LAN address remote ranks can actually
528/// dial. If ONLY loopback resolves, return it WITH a warning string
529/// (likely misconfig — better to surface than to silently ship an
530/// unreachable IP).
531fn resolve_host_to_ip(host: &str) -> (Option<String>, Option<String>) {
532 use std::net::ToSocketAddrs;
533 // Already a numeric address? Skip the lookup, return as-is.
534 if host.parse::<std::net::IpAddr>().is_ok() {
535 return (Some(host.to_string()), None);
536 }
537 match (host, 0u16).to_socket_addrs() {
538 Ok(iter) => {
539 let (ip, only_loopback) = select_preferred_ip(iter.map(|sa| sa.ip()));
540 let warning = match (&ip, only_loopback) {
541 (Some(ip), true) => Some(format!(
542 "host {host:?} only resolves to loopback {ip} on the controller \
543 — remote ranks will fail to connect. Set the controller's `host:` \
544 in fdl.cluster.yml to a non-loopback IP reachable from peer nodes \
545 (e.g. the libvirt bridge IP 192.168.122.1 for virbr0). An explicit \
546 IP is used as-is (it is the address ranks dial), bypassing this \
547 hostname resolution; on a multi-NIC controller this is also how you \
548 pin which address is advertised."
549 )),
550 _ => None,
551 };
552 (ip, warning)
553 }
554 Err(e) => (
555 None,
556 Some(format!(
557 "host {host:?} did not resolve on controller: {e} (remote ranks \
558 will retry via their own NSS — fix host-side resolution if they \
559 also fail)"
560 )),
561 ),
562 }
563}
564
565/// Pick the best IP from a `getaddrinfo` iterator: first non-loopback
566/// wins; if every candidate is loopback, return the first loopback
567/// with `only_loopback=true` so the caller can warn. Pure function,
568/// no NSS dependency — exists so the selection rule is unit-testable.
569fn select_preferred_ip<I: IntoIterator<Item = std::net::IpAddr>>(
570 iter: I,
571) -> (Option<String>, bool) {
572 let mut loopback_fallback: Option<String> = None;
573 for ip in iter {
574 if !ip.is_loopback() {
575 return (Some(ip.to_string()), false);
576 }
577 loopback_fallback.get_or_insert_with(|| ip.to_string());
578 }
579 let only_loopback = loopback_fallback.is_some();
580 (loopback_fallback, only_loopback)
581}
582
583/// Write a temporary docker-compose overlay (under `project_root`)
584/// that populates `extra_hosts:` for the cluster-capable services
585/// (`cuda`, `dev`, `bench`) from the controller-resolved cluster
586/// hosts in [`ENV_CLUSTER_EXTRA_HOSTS`], then return the `-f` flag
587/// sequence to splice in front of `docker compose run`.
588///
589/// `docker compose run` itself does not accept `--add-host` (that's
590/// `docker run` only), but compose merges multiple `-f` files, so the
591/// overlay extends the base config without mutating it.
592///
593/// Returns the empty string (and writes nothing) when not in cluster
594/// mode — non-cluster runs keep their existing `docker compose run`
595/// invocation unchanged.
596pub fn cluster_compose_overlay_arg(project_root: &Path) -> String {
597 let raw = match std::env::var(ENV_CLUSTER_EXTRA_HOSTS) {
598 Ok(s) => s,
599 Err(_) => return String::new(),
600 };
601 let pairs: Vec<&str> = raw.split_whitespace().filter(|p| !p.is_empty()).collect();
602 if pairs.is_empty() {
603 return String::new();
604 }
605
606 let mut entries = String::new();
607 for pair in &pairs {
608 entries.push_str(" - \"");
609 // Escape for a YAML double-quoted scalar so a `\` or `"` in the
610 // value can't break out of the entry. Hostnames/IPs shouldn't
611 // contain these, but the `host:` half comes from user-authored
612 // cluster.yml, so don't trust it into a quoted string unescaped.
613 for ch in pair.chars() {
614 match ch {
615 '\\' => entries.push_str("\\\\"),
616 '"' => entries.push_str("\\\""),
617 _ => entries.push(ch),
618 }
619 }
620 entries.push_str("\"\n");
621 }
622
623 // extra_hosts is per-service in compose; apply to every
624 // cluster-capable service so the same override file works
625 // regardless of which one the dispatch lands on.
626 let overlay = format!(
627 "# Generated by fdl-cli (cluster mode) — DO NOT EDIT BY HAND.\n\
628 # Regenerated on every `fdl @cluster ...` invocation.\n\
629 services:\n\
630 \x20\x20cuda:\n\
631 \x20\x20\x20\x20extra_hosts:\n{entries}\
632 \x20\x20dev:\n\
633 \x20\x20\x20\x20extra_hosts:\n{entries}\
634 \x20\x20bench:\n\
635 \x20\x20\x20\x20extra_hosts:\n{entries}",
636 );
637
638 let overlay_path = project_root.join(".fdl-cluster-overlay.yml");
639 if let Err(e) = std::fs::write(&overlay_path, overlay) {
640 eprintln!(
641 "fdl: warning: failed to write cluster compose overlay at {:?}: {e} \
642 (continuing without --add-host injection — remote hostnames \
643 may not resolve inside the container)",
644 overlay_path
645 );
646 return String::new();
647 }
648
649 // base docker-compose.yml first, then our overlay second, so the
650 // overlay's extra_hosts merges into the base service definitions.
651 // Quoted: this string is spliced into an `sh -c` command line, so a
652 // project path with a space would otherwise shatter the parse.
653 format!(
654 " -f docker-compose.yml -f {}",
655 crate::util::shell::posix_quote(&overlay_path.display().to_string())
656 )
657}
658
659/// Hex-encode raw bytes (lowercase, no separators). Companion to the
660/// library's `hex_decode` in `flodl::distributed::cluster`. Kept here
661/// so `prepare_cluster_env` doesn't pull in a flodl runtime dep.
662pub fn hex_encode(bytes: &[u8]) -> String {
663 const TABLE: &[u8; 16] = b"0123456789abcdef";
664 let mut s = String::with_capacity(bytes.len() * 2);
665 for &b in bytes {
666 s.push(TABLE[(b >> 4) as usize] as char);
667 s.push(TABLE[(b & 0x0F) as usize] as char);
668 }
669 s
670}
671
672/// Resolve the controller's OS user name. Used to pre-populate
673/// [`ENV_HOST_USER`] before docker spawn, so the launcher inside the
674/// container can default `ssh -l <user>` to the host's identity.
675/// Falls through `USER` then `whoami`; `None` when both fail — the
676/// caller then leaves [`ENV_HOST_USER`] unset and the launcher omits
677/// `-l` entirely, so ssh applies its own defaults (`ssh_config User`
678/// directives, the effective uid). Never fabricate a name: a made-up
679/// `-l unknown-user` produced a bare "Permission denied (publickey)"
680/// with no hint the username was invented.
681pub fn resolve_local_user() -> Option<String> {
682 resolve_user_from(
683 std::env::var("USER").ok().as_deref(),
684 Command::new("whoami").output().ok().and_then(|out| {
685 if out.status.success() {
686 String::from_utf8(out.stdout).ok()
687 } else {
688 None
689 }
690 }),
691 )
692}
693
694/// Pure core of [`resolve_local_user`] (unit-testable without env
695/// mutation): first non-empty of the `USER` value and the `whoami`
696/// output, both trimmed.
697fn resolve_user_from(user_env: Option<&str>, whoami_out: Option<String>) -> Option<String> {
698 if let Some(s) = user_env {
699 let s = s.trim();
700 if !s.is_empty() {
701 return Some(s.to_string());
702 }
703 }
704 whoami_out
705 .map(|s| s.trim().to_string())
706 .filter(|s| !s.is_empty())
707}
708
709/// Resolve the local OS hostname. Used by `gpus::synthesize_local_cluster`
710/// (the `--gpus` single-host shorthand) and by `prebuild` to skip the
711/// controller from the remote-host fan-out. Test/override seam via
712/// [`ENV_HOST_OVERRIDE`]; falls back to the `hostname(1)` command.
713pub fn resolve_local_hostname() -> String {
714 if let Ok(s) = std::env::var(ENV_HOST_OVERRIDE) {
715 let s = s.trim().to_string();
716 if !s.is_empty() {
717 return s;
718 }
719 }
720 Command::new("hostname")
721 .output()
722 .ok()
723 .and_then(|out| {
724 if out.status.success() {
725 String::from_utf8(out.stdout)
726 .ok()
727 .map(|s| s.trim().to_string())
728 .filter(|s| !s.is_empty())
729 } else {
730 None
731 }
732 })
733 .unwrap_or_else(|| "unknown-host".to_string())
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use crate::util::test_env::env_lock;
740
741 #[test]
742 fn net_timeout_scale_validation_mirrors_library_rule() {
743 // Pure core — no env mutation needed.
744 assert!(validate_net_timeout_scale_value(None).is_ok());
745 assert!(validate_net_timeout_scale_value(Some("3")).is_ok());
746 assert!(validate_net_timeout_scale_value(Some("0.1")).is_ok());
747 assert!(validate_net_timeout_scale_value(Some(" 2.0 ")).is_ok());
748 assert!(validate_net_timeout_scale_value(Some("0.05")).is_err());
749 assert!(validate_net_timeout_scale_value(Some("-1")).is_err());
750 assert!(validate_net_timeout_scale_value(Some("inf")).is_err());
751 assert!(validate_net_timeout_scale_value(Some("abc")).is_err());
752 }
753
754 #[test]
755 fn resolve_user_never_fabricates() {
756 // Pure core — no env mutation needed.
757 assert_eq!(resolve_user_from(Some("fab"), None).as_deref(), Some("fab"));
758 assert_eq!(resolve_user_from(Some(" fab \n"), None).as_deref(), Some("fab"));
759 // Empty USER falls through to whoami.
760 assert_eq!(
761 resolve_user_from(Some(""), Some("who\n".into())).as_deref(),
762 Some("who"),
763 );
764 assert_eq!(resolve_user_from(None, Some("who".into())).as_deref(), Some("who"));
765 // Double failure -> None (never a fabricated "unknown-user").
766 assert_eq!(resolve_user_from(None, None), None);
767 assert_eq!(resolve_user_from(Some(" "), Some(" ".into())), None);
768 }
769
770 #[test]
771 fn should_dispatch_returns_false_when_cluster_json_set() {
772 let _guard = env_lock();
773 // SAFETY: serialized via env_lock() above.
774 unsafe {
775 std::env::set_var(ENV_CLUSTER_JSON, "deadbeef");
776 }
777 let yaml = "\
778cluster:
779 controller:
780 host: 127.0.0.1
781 port: 29500
782 path: /opt/flodl
783 workers:
784 - host: solo
785 local_devices: [0]
786 nccl_socket_ifname: lo
787 path: /opt/flodl
788commands:
789 x: { cluster: true, run: \"echo hi\" }
790";
791 let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
792 assert!(
793 !should_dispatch(&project, &[Some(true)]),
794 "recursion guard: must return false when FLODL_INTERNAL_CLUSTER_JSON is set"
795 );
796 unsafe {
797 std::env::remove_var(ENV_CLUSTER_JSON);
798 }
799 }
800
801 #[test]
802 fn should_dispatch_delegates_when_env_unset() {
803 let _guard = env_lock();
804 unsafe {
805 std::env::remove_var(ENV_CLUSTER_JSON);
806 }
807 let yaml = "\
808cluster:
809 controller:
810 host: 127.0.0.1
811 port: 29500
812 path: /opt/flodl
813 workers:
814 - host: solo
815 local_devices: [0]
816 nccl_socket_ifname: lo
817 path: /opt/flodl
818commands:
819 x: { run: \"echo hi\" }
820";
821 let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
822 assert!(!should_dispatch(&project, &[None]));
823 assert!(should_dispatch(&project, &[Some(true)]));
824 }
825
826 #[test]
827 fn hex_encode_matches_library() {
828 // Well-known mappings; library's flodl::distributed::cluster::hex_decode
829 // is the round-trip partner.
830 assert_eq!(hex_encode(b""), "");
831 assert_eq!(hex_encode(&[0x00]), "00");
832 assert_eq!(hex_encode(&[0xff]), "ff");
833 assert_eq!(hex_encode(&[0x0f, 0xa0]), "0fa0");
834 assert_eq!(hex_encode(b"hi"), "6869");
835 }
836
837 #[test]
838 fn prepare_cluster_env_sets_required_vars() {
839 let _guard = env_lock();
840 // Clear env first so we observe what prepare_cluster_env sets.
841 unsafe {
842 std::env::remove_var(ENV_FULL_CLUSTER_JSON);
843 std::env::remove_var(ENV_FDL_CMD);
844 std::env::remove_var(ENV_FDL_ENV);
845 }
846 let yaml = "\
847cluster:
848 controller:
849 host: 127.0.0.1
850 port: 29500
851 path: /opt/flodl
852 workers:
853 - host: solo
854 local_devices: [0]
855 nccl_socket_ifname: lo
856 path: /opt/flodl
857commands:
858 train: { cluster: true, run: \"true\" }
859";
860 let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
861 let cluster = project.cluster.as_ref().unwrap();
862 prepare_cluster_env(cluster, Some("cluster"), "train").expect("prepare OK");
863
864 assert!(!std::env::var(ENV_FULL_CLUSTER_JSON).unwrap().is_empty());
865 assert_eq!(std::env::var(ENV_FDL_CMD).unwrap(), "train");
866 assert_eq!(std::env::var(ENV_FDL_ENV).unwrap(), "cluster");
867
868 // Verify the full envelope round-trips back to the canonical JSON.
869 let hex = std::env::var(ENV_FULL_CLUSTER_JSON).unwrap();
870 // Decode and parse it as JSON.
871 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
872
873 unsafe {
874 std::env::remove_var(ENV_FULL_CLUSTER_JSON);
875 std::env::remove_var(ENV_FDL_CMD);
876 std::env::remove_var(ENV_FDL_ENV);
877 }
878 }
879
880 #[test]
881 fn prepare_cluster_env_skips_fdl_env_when_blank() {
882 let _guard = env_lock();
883 unsafe {
884 std::env::remove_var(ENV_FDL_ENV);
885 }
886 let yaml = "\
887cluster:
888 controller:
889 host: 127.0.0.1
890 port: 29500
891 path: /opt/flodl
892 workers:
893 - host: solo
894 local_devices: [0]
895 nccl_socket_ifname: lo
896 path: /opt/flodl
897commands:
898 train: { cluster: true, run: \"true\" }
899";
900 let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
901 let cluster = project.cluster.as_ref().unwrap();
902 // None overlay → no FDL_ENV var set.
903 prepare_cluster_env(cluster, None, "train").unwrap();
904 assert!(std::env::var_os(ENV_FDL_ENV).is_none());
905
906 // Empty overlay → also no FDL_ENV var.
907 prepare_cluster_env(cluster, Some(" "), "train").unwrap();
908 assert!(std::env::var_os(ENV_FDL_ENV).is_none());
909
910 unsafe {
911 std::env::remove_var(ENV_FULL_CLUSTER_JSON);
912 std::env::remove_var(ENV_FDL_CMD);
913 }
914 }
915
916 #[test]
917 fn select_preferred_ip_prefers_non_loopback() {
918 use std::net::IpAddr;
919 // The Debian/Ubuntu /etc/hosts shape we have to handle:
920 // 127.0.1.1 comes back FIRST, the routable LAN/bridge IP second.
921 let ips: Vec<IpAddr> = vec![
922 "127.0.1.1".parse().unwrap(),
923 "192.168.122.1".parse().unwrap(),
924 ];
925 let (ip, only_loopback) = select_preferred_ip(ips);
926 assert_eq!(ip.as_deref(), Some("192.168.122.1"));
927 assert!(!only_loopback);
928 }
929
930 #[test]
931 fn select_preferred_ip_falls_back_to_loopback_with_flag() {
932 use std::net::IpAddr;
933 // Misconfig case: only loopback resolves. Return it so the
934 // caller still has SOMETHING, but flip the flag so the caller
935 // warns.
936 let ips: Vec<IpAddr> = vec!["127.0.1.1".parse().unwrap(), "::1".parse().unwrap()];
937 let (ip, only_loopback) = select_preferred_ip(ips);
938 assert_eq!(ip.as_deref(), Some("127.0.1.1"));
939 assert!(only_loopback);
940 }
941
942 #[test]
943 fn select_preferred_ip_empty_iterator() {
944 let (ip, only_loopback) = select_preferred_ip(std::iter::empty());
945 assert!(ip.is_none());
946 assert!(!only_loopback);
947 }
948
949 #[test]
950 fn select_preferred_ip_skips_ipv6_loopback() {
951 use std::net::IpAddr;
952 // IPv6 loopback (::1) must be skipped just like 127.x.
953 let ips: Vec<IpAddr> = vec!["::1".parse().unwrap(), "10.0.0.5".parse().unwrap()];
954 let (ip, only_loopback) = select_preferred_ip(ips);
955 assert_eq!(ip.as_deref(), Some("10.0.0.5"));
956 assert!(!only_loopback);
957 }
958
959 #[test]
960 fn prepare_cluster_env_validates_cluster() {
961 let _guard = env_lock();
962 // Empty controller.host → validate() fails → prepare_cluster_env errors.
963 let cluster = ClusterConfig {
964 controller: crate::config::ClusterController {
965 host: String::new(),
966 port: 1337,
967 path: String::new(),
968 docker: None,
969 arch: None,
970 data_path: None,
971 join: None,
972 },
973 workers: Vec::new(),
974 env: std::collections::BTreeMap::new(),
975 };
976 let err = prepare_cluster_env(&cluster, None, "train").unwrap_err();
977 assert!(err.contains("controller.host"), "got: {err}");
978 }
979
980 /// Workers reached via `~/.ssh/config` aliases (or any setup where
981 /// the YAML's `host:` is just a label, not a DNS-resolvable name)
982 /// carry an explicit `ssh.target` in the worker block. In that case
983 /// the controller's NSS does not need to know the `host:` value —
984 /// the connection uses `ssh.target` — so the "did not resolve"
985 /// warning is noise and gets suppressed.
986 ///
987 /// Uses `nonexistent.invalid.` (RFC 2606 reserved) to guarantee
988 /// `getaddrinfo` returns Err on any system regardless of local DNS
989 /// or /etc/hosts content.
990 #[test]
991 fn resolve_cluster_extra_hosts_suppresses_warning_when_ssh_target_explicit() {
992 use crate::config::{
993 ClusterController, ClusterWorker, LocalDevices, SshConfig,
994 };
995 let cluster = ClusterConfig {
996 controller: ClusterController {
997 host: "127.0.0.1".into(),
998 port: 1337,
999 path: "/tmp".into(),
1000 docker: None,
1001 arch: None,
1002 data_path: None,
1003 join: None,
1004 },
1005 workers: vec![ClusterWorker {
1006 host: "nonexistent.invalid.".into(),
1007 ranks: vec![0],
1008 local_devices: LocalDevices::Explicit(vec![0]),
1009 nccl_socket_ifname: "lo".into(),
1010 path: "/tmp".into(),
1011 ssh: Some(SshConfig {
1012 target: Some("127.0.0.1".into()),
1013 ..SshConfig::default()
1014 }),
1015 tunnel: false,
1016 arch: None,
1017 data_path: None,
1018 docker: None,
1019 env: std::collections::BTreeMap::new(),
1020 }],
1021 env: std::collections::BTreeMap::new(),
1022 };
1023 let (_hosts, warnings) = resolve_cluster_extra_hosts(&cluster);
1024 assert!(
1025 warnings.is_empty(),
1026 "explicit ssh.target should suppress the resolution warning, \
1027 got warnings: {warnings:?}"
1028 );
1029 }
1030
1031 /// Mirror of the suppression test: without `ssh.target` the warning
1032 /// must still fire (so legitimate misconfigurations stay visible).
1033 #[test]
1034 fn resolve_cluster_extra_hosts_warns_when_ssh_target_absent() {
1035 use crate::config::{ClusterController, ClusterWorker, LocalDevices};
1036 let cluster = ClusterConfig {
1037 controller: ClusterController {
1038 host: "127.0.0.1".into(),
1039 port: 1337,
1040 path: "/tmp".into(),
1041 docker: None,
1042 arch: None,
1043 data_path: None,
1044 join: None,
1045 },
1046 workers: vec![ClusterWorker {
1047 host: "nonexistent.invalid.".into(),
1048 ranks: vec![0],
1049 local_devices: LocalDevices::Explicit(vec![0]),
1050 nccl_socket_ifname: "lo".into(),
1051 path: "/tmp".into(),
1052 ssh: None,
1053 tunnel: false,
1054 arch: None,
1055 data_path: None,
1056 docker: None,
1057 env: std::collections::BTreeMap::new(),
1058 }],
1059 env: std::collections::BTreeMap::new(),
1060 };
1061 let (_hosts, warnings) = resolve_cluster_extra_hosts(&cluster);
1062 assert!(
1063 warnings.iter().any(|w| w.contains("did not resolve")),
1064 "missing ssh.target should keep the warning, got warnings: {warnings:?}"
1065 );
1066 }
1067}