Skip to main content

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}