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        // The AMD masks outrank CUDA_VISIBLE_DEVICES for HIP (first one
105        // set wins), so an env-block value would silently defeat the
106        // launcher's per-rank pin — the same reservation, other vendor.
107        || key == "HIP_VISIBLE_DEVICES"
108        || key == "ROCR_VISIBLE_DEVICES"
109        || key == "GPU_DEVICE_ORDINAL"
110        || key == ENV_HOST_OVERRIDE
111        || key == ENV_FDL_ENV
112}
113
114/// Env var scaling every flodl cluster network deadline together
115/// (connect budget, write-stall, heartbeat staleness, coord-liveness,
116/// CPU reduce read). Mirrors `flodl::distributed::wire`'s constant +
117/// validation rule (kept in lockstep — flodl-cli is decoupled from the
118/// library crate). The library reader warns-and-defaults on a bad
119/// value; the cluster fan-out path calls
120/// [`validate_net_timeout_scale`] first so an explicit-but-invalid
121/// value errors loudly BEFORE any host is touched.
122pub const ENV_NET_TIMEOUT_SCALE: &str = "FLODL_NET_TIMEOUT_SCALE";
123
124/// Validate `FLODL_NET_TIMEOUT_SCALE` from the process env: unset is
125/// fine (scale 1.0); a set value must parse as a finite float ≥ 0.1.
126/// Mirrors the library's parse rule.
127pub fn validate_net_timeout_scale() -> Result<(), String> {
128    validate_net_timeout_scale_value(std::env::var(ENV_NET_TIMEOUT_SCALE).ok().as_deref())
129}
130
131/// Pure core of [`validate_net_timeout_scale`] (unit-testable without
132/// env mutation).
133fn validate_net_timeout_scale_value(raw: Option<&str>) -> Result<(), String> {
134    let Some(raw) = raw else { return Ok(()) };
135    let trimmed = raw.trim();
136    match trimmed.parse::<f64>() {
137        Ok(v) if v.is_finite() && v >= 0.1 => Ok(()),
138        Ok(_) => Err(format!(
139            "{ENV_NET_TIMEOUT_SCALE}={trimmed} is out of range; expected a \
140             finite scale factor >= 0.1 (0.1 keeps every deadline above the \
141             1s heartbeat cadence)"
142        )),
143        Err(_) => Err(format!(
144            "{ENV_NET_TIMEOUT_SCALE}={trimmed:?} is not a number; expected a \
145             scale factor >= 0.1 (e.g. 3 for a slow WAN link, 0.5 for a \
146             fast-failure test rig)"
147        )),
148    }
149}
150
151/// Top-level cluster-dispatch decision.
152///
153/// Returns `false` when `FLODL_INTERNAL_CLUSTER_JSON` is set — that signals we're
154/// a recursive fdl invocation on a remote host that the launcher's ssh
155/// fan-out reached, and we should fall through to normal dispatch.
156/// Otherwise delegates to [`config::cluster_dispatch_enabled`].
157pub fn should_dispatch(project: &ProjectConfig, chain: &[Option<bool>]) -> bool {
158    if is_recursive_invocation() {
159        return false;
160    }
161    config::cluster_dispatch_enabled(project, chain)
162}
163
164/// Whether this fdl invocation is itself a spawned child of a launcher's
165/// ssh fan-out (`FLODL_INTERNAL_CLUSTER_JSON` already set in env). Used as the
166/// recursion guard everywhere cluster dispatch is evaluated.
167pub fn is_recursive_invocation() -> bool {
168    std::env::var_os(ENV_CLUSTER_JSON).is_some()
169}
170
171/// A configured join window that this command will not open.
172///
173/// A farm overlay declares `controller.join.discovery`, but the window
174/// only opens for a command running in launcher mode, which is what
175/// `cluster: true` selects. Miss that and the command resolves against
176/// the base config and trains locally — and nothing about the run says
177/// so, because training on this box is a legitimate thing to do. Silence
178/// here reads as "my GPUs were not detected" rather than "my farm never
179/// engaged", so the mismatch is worth one line on stderr.
180///
181/// Pure: takes the merged config, returns the text. Returns `None` when
182/// there is no discovery window to miss.
183pub fn unused_join_window_hint(project: &ProjectConfig, command: &str) -> Option<String> {
184    let join = project.cluster.as_ref()?.controller.join.as_ref()?;
185    if join.discovery != Some(true) {
186        return None;
187    }
188    Some(format!(
189        "this env declares a join window (controller.join.discovery), but \
190         `{command}` is not a cluster command, so it runs HERE and no \
191         window opens. Add it to the env's `commands:` with `cluster: \
192         true` to put it in launcher mode."
193    ))
194}
195
196/// Prepare the env vars needed for the user binary's flodl launcher to
197/// detect launcher role and fan out. Caller continues to normal
198/// dispatch (`RunScript` / `ExecCommand`); the spawned subprocess
199/// inherits these env vars and the launcher takes over.
200///
201/// `overlay_env` is the overlay name from `fdl @<env>` (e.g.
202/// `Some("cluster")`); propagated to remote hosts via the launcher so
203/// they see the same overlay-merged `commands:` resolution.
204///
205/// Returns `Err` if the cluster config is invalid or JSON serialization
206/// fails — surfaces the error before the user binary even starts.
207///
208/// On success returns a `Vec<String>` of non-fatal resolution warnings
209/// (one entry per host whose NSS lookup failed or yielded only loopback
210/// addresses). The cluster-dispatch site in `main.rs` is the one that
211/// chooses to print them. Tests that exercise this function for its
212/// env-setting behavior simply ignore the returned Vec.
213pub fn prepare_cluster_env(
214    cluster: &ClusterConfig,
215    overlay_env: Option<&str>,
216    cmd: &str,
217) -> Result<Vec<String>, String> {
218    cluster.validate()?;
219    let mut warnings: Vec<String> = Vec::new();
220    // Pre-resolve `controller.host` on the controller (where NSS knows
221    // names declared in `/etc/hosts`, `libnss-libvirt`, mDNS, etc.)
222    // and ship the resolved IP in the envelope to remote ranks. Remote
223    // VMs that don't share the controller's NSS view (a Pascal VM on
224    // libvirt's virbr0 has no plugin to resolve "exa") then connect
225    // by numeric IP without needing their own resolver to know cluster
226    // hostnames. If resolution fails on the controller, ship the
227    // original string and let the remote try its own NSS as a last
228    // resort.
229    let mut shippable = cluster.clone();
230    let (controller_ip, controller_warning) = resolve_host_to_ip(&shippable.controller.host);
231    if let Some(ip) = controller_ip {
232        shippable.controller.host = ip;
233    }
234    if let Some(w) = controller_warning {
235        warnings.push(w);
236    }
237    // Probe device counts per worker, then populate global ranks by
238    // sequential assignment. `ranks` is not user-facing in the YAML
239    // schema (see `ClusterWorker::ranks` — `skip_deserializing`); the
240    // probe result is authoritative. Probing only SSHes for workers
241    // that use `local_devices: all`; explicit lists carry their own
242    // count. After populate_ranks the cluster shape on the wire is
243    // identical to the legacy explicit form.
244    let counts = probe_worker_device_counts(&shippable)?;
245    shippable.populate_ranks(&counts)?;
246    shippable.validate()?;
247    let json = shippable.canonical_json()?;
248    let hex = hex_encode(json.as_bytes());
249    let (extra_hosts, host_warnings) = resolve_cluster_extra_hosts(cluster);
250    warnings.extend(host_warnings);
251
252    // Controller user for the launcher's default `ssh -l`. On the rare
253    // double-failure (no USER, no whoami) leave the env UNSET — the
254    // launcher then omits `-l` and ssh applies its own defaults — and
255    // warn, instead of shipping a fabricated username into ssh auth.
256    let host_user = resolve_local_user();
257    if host_user.is_none() {
258        warnings.push(
259            "could not determine the controller's user (USER unset, whoami \
260             unavailable); ssh will use its own defaults — set `ssh.user:` \
261             per host in fdl.cluster.yml if remote accounts differ"
262                .to_string(),
263        );
264    }
265    // SAFETY: main() has not spawned threads at this point in the
266    // dispatch flow (mirrors gpus::apply_cuda_visible_devices's
267    // invariant; documented in main.rs).
268    unsafe {
269        std::env::set_var(ENV_FULL_CLUSTER_JSON, &hex);
270        std::env::set_var(ENV_FDL_CMD, cmd);
271        if let Some(u) = &host_user {
272            std::env::set_var(ENV_HOST_USER, u);
273        }
274        if !extra_hosts.is_empty() {
275            std::env::set_var(ENV_CLUSTER_EXTRA_HOSTS, extra_hosts.join(" "));
276        }
277        if let Some(e) = overlay_env
278            && !e.trim().is_empty()
279        {
280            std::env::set_var(ENV_FDL_ENV, e);
281        }
282    }
283    Ok(warnings)
284}
285
286/// Local-only sibling of [`probe_worker_device_counts`], used by the
287/// testing-envelope export path in [`prepare_test_cluster_env`].
288///
289/// Testing-mode cluster invocations (`fdl @cluster-test <cmd>`) run the
290/// test binary in-process on one host; there's no SSH fan-out, so any
291/// worker declaring `local_devices: all` is by definition referring to
292/// the local machine's visible GPUs. Use `nvidia-smi -L` locally
293/// instead of SSHing back to ourselves.
294///
295/// - `LocalDevices::Explicit(v)` → `v.len()`
296/// - `LocalDevices::All` → [`crate::gpus::local_gpu_count`]
297///   (result cached across workers since they all resolve to the same
298///   local box in testing mode).
299///
300/// Errors loudly when no GPU is detected, quoting the reason (caller
301/// treats 0 as misconfiguration — no GPUs visible to the test).
302fn probe_local_device_counts(cluster: &ClusterConfig) -> Result<Vec<usize>, String> {
303    let mut counts = Vec::with_capacity(cluster.workers.len());
304    let mut cached_local: Option<usize> = None;
305    for (i, w) in cluster.workers.iter().enumerate() {
306        let count = match &w.local_devices {
307            config::LocalDevices::Explicit(v) => v.len(),
308            config::LocalDevices::All => {
309                if cached_local.is_none() {
310                    cached_local = Some(crate::gpus::local_gpu_count().map_err(|e| {
311                        format!(
312                            "cluster.workers[{i}] ({:?}): local GPU probe \
313                                 failed: {e}",
314                            w.host,
315                        )
316                    })?);
317                }
318                cached_local.unwrap()
319            }
320        };
321        if count == 0 {
322            return Err(format!(
323                "cluster.workers[{i}] ({:?}): 0 CUDA devices visible \
324                 (local_devices: all). Run `fdl @cluster-test <cmd>` on a \
325                 host with visible GPUs, or use an explicit \
326                 `local_devices: [...]` list.",
327                w.host,
328            ));
329        }
330        counts.push(count);
331    }
332    Ok(counts)
333}
334
335/// Prepare the testing-cluster envelope (`FLODL_TESTING_CLUSTER_JSON`).
336///
337/// Mirrors [`prepare_cluster_env`] for the testing path: clones the
338/// cluster, probes device counts LOCALLY (no SSH — tests run
339/// in-process on the local host), populates ranks by sequential
340/// assignment, then serializes for shipping.
341///
342/// Returns the hex-encoded envelope ready to set in the env. Errors
343/// surface from `cluster.validate()` (structural), the local probe,
344/// or `populate_ranks` (count/worker mismatch).
345pub fn prepare_test_cluster_env(cluster: &ClusterConfig) -> Result<String, String> {
346    cluster.validate()?;
347    let mut shippable = cluster.clone();
348    let counts = probe_local_device_counts(&shippable)?;
349    shippable.populate_ranks(&counts)?;
350    shippable.validate()?;
351    let json = shippable.canonical_json()?;
352    Ok(hex_encode(json.as_bytes()))
353}
354
355/// Probe each worker's CUDA device count.
356///
357/// - `LocalDevices::Explicit(v)` → `v.len()` (no SSH, no remote call)
358/// - `LocalDevices::All` → SSH to the worker and run
359///   `nvidia-smi --query-gpu=index --format=csv,noheader 2>/dev/null | wc -l`,
360///   parse as `usize`. Errors loudly on SSH failure, parse failure, or
361///   a 0 count (caller treats 0 as misconfiguration).
362///
363/// Returns one count per worker, in worker-declaration order. Used by
364/// [`prepare_cluster_env`] before envelope emission so global rank
365/// assignment can be sequential without requiring users to enumerate
366/// `ranks:` in YAML.
367fn probe_worker_device_counts(cluster: &ClusterConfig) -> Result<Vec<usize>, String> {
368    let mut counts = Vec::with_capacity(cluster.workers.len());
369    for (i, w) in cluster.workers.iter().enumerate() {
370        let count = match &w.local_devices {
371            config::LocalDevices::Explicit(v) => v.len(),
372            config::LocalDevices::All => ssh_query_gpu_count(w)
373                .map_err(|e| format!("cluster.workers[{i}] ({:?}): probe failed: {e}", w.host,))?,
374        };
375        if count == 0 {
376            return Err(format!(
377                "cluster.workers[{i}] ({:?}): probed 0 GPUs \
378                 (local_devices: all). Either the host has no GPUs visible \
379                 (NVIDIA: `nvidia-smi` and the visibility masks; AMD: \
380                 `/dev/kfd` and a loaded amdgpu driver) or it's a \
381                 misconfiguration — provide an explicit `local_devices: [...]` \
382                 list instead.",
383                w.host,
384            ));
385        }
386        counts.push(count);
387    }
388    Ok(counts)
389}
390
391/// SSH to a worker and count visible CUDA devices via `nvidia-smi`.
392///
393/// Honors the worker's `ssh:` sub-block (target / port / user /
394/// identity_file / options). Falls back to `host` as the ssh target
395/// when no `ssh:` block is provided; system ssh resolves the rest via
396/// `~/.ssh/config`.
397///
398/// Times out after 5s on connect to keep cluster startup snappy when a
399/// host is unreachable. Uses `BatchMode=yes` so a passphrase prompt
400/// doesn't hang the dispatch.
401/// Apply a worker's `ssh:` sub-block (port / user / identity_file /
402/// options) as flags on an `ssh` `Command`.
403///
404/// Call this BEFORE pushing flodl's default connect-behavior flags (`-T`,
405/// `BatchMode`, timeouts), then the target host + remote command. User
406/// `ssh.options` are emitted here first so they take precedence: OpenSSH uses
407/// the first value seen for each `-o`, so flodl's later defaults fill in only
408/// the options the user didn't set (M17). The one option flodl truly needs —
409/// `BatchMode=yes`, so its non-interactive ssh never hangs on a prompt — is
410/// still overridable, but [`batchmode_override_warning`] flags it loudly.
411///
412/// Shared by cluster GPU-count dispatch ([`ssh_query_gpu_count`]) and
413/// `fdl probe`'s remote fan-out (`probe::probe_remote_via_ssh`) so both
414/// reach a host the same way — notably a Docker-container rank exposed
415/// on `127.0.0.1:<port>` with an `identity_file` (without these flags
416/// ssh defaults to port 22 / the login user and the connect is
417/// refused). The `target` itself is set by the caller (it falls back to
418/// `worker.host` when no `ssh.target` is declared).
419pub(crate) fn apply_worker_ssh_opts(cmd: &mut Command, worker: &config::ClusterWorker) {
420    if let Some(ssh) = worker.ssh.as_ref() {
421        if let Some(port) = ssh.port {
422            cmd.arg("-p").arg(port.to_string());
423        }
424        if let Some(user) = ssh.user.as_deref() {
425            cmd.arg("-l").arg(user);
426        }
427        if let Some(id) = ssh.identity_file.as_deref() {
428            cmd.arg("-i").arg(id);
429        }
430        if let Some(warning) = batchmode_override_warning(&ssh.options, &worker.host) {
431            eprintln!("{warning}");
432        }
433        for opt in &ssh.options {
434            cmd.arg("-o").arg(opt);
435        }
436    }
437}
438
439/// The warning message when a worker's `ssh.options` set `BatchMode` to a
440/// non-`yes` value, else `None`. flodl's remote ssh (dispatch + probes) is
441/// non-interactive, so a prompt hangs it; `BatchMode=yes` is the one truly
442/// required ssh option. Every other flodl default is freely overridable (M17).
443pub(crate) fn batchmode_override_warning(opts: &[String], host: &str) -> Option<String> {
444    opts.iter().find_map(|opt| {
445        let (k, v) = opt.split_once('=')?;
446        (k.trim().eq_ignore_ascii_case("BatchMode") && !v.trim().eq_ignore_ascii_case("yes")).then(
447            || {
448                format!(
449                    "fdl: host {host:?} ssh.options set `{}` — flodl's ssh is \
450                 non-interactive and will hang on any prompt (passphrase, \
451                 host-key). Proceeding as requested.",
452                    opt.trim()
453                )
454            },
455        )
456    })
457}
458
459fn ssh_query_gpu_count(worker: &config::ClusterWorker) -> Result<usize, String> {
460    let target = worker
461        .ssh
462        .as_ref()
463        .and_then(|s| s.target.as_deref())
464        .unwrap_or(&worker.host);
465    let mut cmd = Command::new("ssh");
466    // User ssh.options first (they win), then flodl's defaults (M17).
467    apply_worker_ssh_opts(&mut cmd, worker);
468    cmd.args(["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]);
469    cmd.arg(target);
470    // Both vendors in one round trip, because "how many GPUs" is not an
471    // NVIDIA question: counting only nvidia-smi made an AMD worker probe
472    // 0 and abort the fan-out, with an error naming a tool that host
473    // does not have. Each count is piped to `wc -l` for a numeric line
474    // and each error stream is silenced, so an absent driver or an
475    // unmatched glob produces "0" rather than parse noise. The AMD side
476    // reads the KFD topology's `vendor_id 4098` (0x1002), which is
477    // flodl-hw's primary AMD gate and mask-proof by construction.
478    cmd.arg(
479        "n=$(nvidia-smi --query-gpu=index --format=csv,noheader 2>/dev/null | wc -l); \
480         a=$(grep -l '^vendor_id 4098$' /sys/class/kfd/kfd/topology/nodes/*/properties \
481         2>/dev/null | wc -l); echo \"$n $a\"",
482    );
483
484    let output = cmd.output().map_err(|e| format!("ssh spawn failed: {e}"))?;
485    if !output.status.success() {
486        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
487        return Err(format!(
488            "ssh to {target:?} exited {} (stderr: {stderr})",
489            output.status,
490        ));
491    }
492    let stdout = String::from_utf8_lossy(&output.stdout);
493    let counts = stdout.trim();
494    let (nvidia, amd) = parse_gpu_counts(counts)
495        .ok_or_else(|| format!("could not parse the remote GPU counts (got {counts:?})"))?;
496    pick_worker_count(nvidia, amd, worker.arch.as_deref(), target)
497}
498
499/// Split the two-number reply of the remote count probe.
500///
501/// Reads the LAST non-empty line: the probe echoes one, but a remote
502/// profile script that prints anything would otherwise shift the fields
503/// and turn a working host into a parse error.
504fn parse_gpu_counts(text: &str) -> Option<(usize, usize)> {
505    let line = text.lines().rev().find(|l| !l.trim().is_empty())?;
506    let mut it = line.split_whitespace();
507    let nvidia = it.next()?.parse().ok()?;
508    let amd = it.next()?.parse().ok()?;
509    Some((nvidia, amd))
510}
511
512/// Reduce the per-vendor counts to the number of ranks this worker owns.
513///
514/// A libtorch build serves one vendor, so a host's rank count is the
515/// count for the vendor it will actually run, which its declared
516/// `arch:` names. Without that declaration a single-vendor host is still
517/// unambiguous, and a host reporting both is genuinely undecidable here:
518/// the controller cannot know which build that box will load, and
519/// guessing assigns ranks to devices nobody will address.
520fn pick_worker_count(
521    nvidia: usize,
522    amd: usize,
523    arch: Option<&str>,
524    target: &str,
525) -> Result<usize, String> {
526    match arch.and_then(crate::libtorch::detect::variant_vendor) {
527        Some(crate::util::system::GpuVendor::Nvidia) => return Ok(nvidia),
528        Some(crate::util::system::GpuVendor::Amd) => return Ok(amd),
529        _ => {}
530    }
531    match (nvidia, amd) {
532        (0, a) => Ok(a),
533        (n, 0) => Ok(n),
534        (n, a) => Err(format!(
535            "{target:?} reports {n} NVIDIA and {a} AMD GPU(s), and one \
536             libtorch build serves one vendor, so which of them this host \
537             trains on cannot be inferred. Declare the host's `arch:` (the \
538             libtorch variant it uses) or pin `local_devices: [...]`."
539        )),
540    }
541}
542
543/// Resolve each cluster worker's `host` to an IP via the controller's
544/// NSS (which on Linux includes static `/etc/hosts`, `libnss-libvirt`,
545/// `libnss-mdns`, and DNS — anything `getaddrinfo` knows about).
546/// Returns `(Vec<"host:ip">, Vec<warning>)`: the first list is suitable
547/// for `--add-host` injection into `docker compose run`; the second is
548/// human-readable warnings the cluster-dispatch site can surface to the
549/// user. Workers that fail to resolve are skipped from the `host:ip`
550/// list (better-than-nothing semantics for the launcher inside the
551/// container — the unresolved host will retry via its own NSS).
552fn resolve_cluster_extra_hosts(cluster: &ClusterConfig) -> (Vec<String>, Vec<String>) {
553    let mut hosts = Vec::new();
554    let mut warnings = Vec::new();
555    for w in &cluster.workers {
556        let (ip, warning) = resolve_host_to_ip(&w.host);
557        if let Some(ip) = ip {
558            hosts.push(format!("{}:{ip}", w.host));
559        }
560        // Suppress the warning when the worker carries an explicit
561        // `ssh.target`. The `host:` value is then just a label — the
562        // actual connection uses ssh.target (or `host:` only as the
563        // default ssh target, which is itself a system-ssh lookup, not
564        // a process-controller-side NSS lookup). Workers reached via
565        // ~/.ssh/config aliases (e.g. a `node-b` alias mapping to
566        // 127.0.0.1:2222) routinely don't resolve via host NSS, and
567        // surfacing the warning every run is noise.
568        let has_explicit_ssh_target = w.ssh.as_ref().and_then(|s| s.target.as_deref()).is_some();
569        if let Some(msg) = warning
570            && !has_explicit_ssh_target
571        {
572            warnings.push(msg);
573        }
574    }
575    (hosts, warnings)
576}
577
578/// Resolve a hostname to an IP string via `getaddrinfo`. Returns
579/// `(Option<ip>, Option<warning>)` — both are independently optional so
580/// the caller can ship the resolved IP AND surface the warning, or
581/// either alone. The function itself is silent; warnings are returned
582/// for the cluster-dispatch site to emit (or ignore in non-dispatch
583/// contexts like unit tests that exercise the env-setting paths).
584///
585/// Prefers a non-loopback address when `getaddrinfo` returns several
586/// candidates. Debian/Ubuntu install `/etc/hosts` with a
587/// `127.0.1.1 <hostname>` line by default, which `getaddrinfo` returns
588/// FIRST — that IP works for the local host but is unreachable from
589/// any peer (a libvirt VM, another rig). Skipping loopback in the
590/// iterator picks the bridge / LAN address remote ranks can actually
591/// dial. If ONLY loopback resolves, return it WITH a warning string
592/// (likely misconfig — better to surface than to silently ship an
593/// unreachable IP).
594fn resolve_host_to_ip(host: &str) -> (Option<String>, Option<String>) {
595    use std::net::ToSocketAddrs;
596    // Already a numeric address? Skip the lookup, return as-is.
597    if host.parse::<std::net::IpAddr>().is_ok() {
598        return (Some(host.to_string()), None);
599    }
600    match (host, 0u16).to_socket_addrs() {
601        Ok(iter) => {
602            let (ip, only_loopback) = select_preferred_ip(iter.map(|sa| sa.ip()));
603            let warning = match (&ip, only_loopback) {
604                (Some(ip), true) => Some(format!(
605                    "host {host:?} only resolves to loopback {ip} on the controller \
606                     — remote ranks will fail to connect. Set the controller's `host:` \
607                     in fdl.cluster.yml to a non-loopback IP reachable from peer nodes \
608                     (e.g. the libvirt bridge IP 192.168.122.1 for virbr0). An explicit \
609                     IP is used as-is (it is the address ranks dial), bypassing this \
610                     hostname resolution; on a multi-NIC controller this is also how you \
611                     pin which address is advertised."
612                )),
613                _ => None,
614            };
615            (ip, warning)
616        }
617        Err(e) => (
618            None,
619            Some(format!(
620                "host {host:?} did not resolve on controller: {e} (remote ranks \
621                 will retry via their own NSS — fix host-side resolution if they \
622                 also fail)"
623            )),
624        ),
625    }
626}
627
628/// Pick the best IP from a `getaddrinfo` iterator: first non-loopback
629/// wins; if every candidate is loopback, return the first loopback
630/// with `only_loopback=true` so the caller can warn. Pure function,
631/// no NSS dependency — exists so the selection rule is unit-testable.
632fn select_preferred_ip<I: IntoIterator<Item = std::net::IpAddr>>(
633    iter: I,
634) -> (Option<String>, bool) {
635    let mut loopback_fallback: Option<String> = None;
636    for ip in iter {
637        if !ip.is_loopback() {
638            return (Some(ip.to_string()), false);
639        }
640        loopback_fallback.get_or_insert_with(|| ip.to_string());
641    }
642    let only_loopback = loopback_fallback.is_some();
643    (loopback_fallback, only_loopback)
644}
645
646/// Write a temporary docker-compose overlay (under `project_root`)
647/// that populates `extra_hosts:` for the cluster-capable services
648/// (`cuda`, `dev`, `bench`) from the controller-resolved cluster
649/// hosts in [`ENV_CLUSTER_EXTRA_HOSTS`], then return the `-f` flag
650/// sequence to splice in front of `docker compose run`.
651///
652/// `docker compose run` itself does not accept `--add-host` (that's
653/// `docker run` only), but compose merges multiple `-f` files, so the
654/// overlay extends the base config without mutating it.
655///
656/// Returns the empty string (and writes nothing) when not in cluster
657/// mode — non-cluster runs keep their existing `docker compose run`
658/// invocation unchanged.
659pub fn cluster_compose_overlay_arg(project_root: &Path) -> String {
660    let raw = match std::env::var(ENV_CLUSTER_EXTRA_HOSTS) {
661        Ok(s) => s,
662        Err(_) => return String::new(),
663    };
664    let pairs: Vec<&str> = raw.split_whitespace().filter(|p| !p.is_empty()).collect();
665    if pairs.is_empty() {
666        return String::new();
667    }
668
669    let mut entries = String::new();
670    for pair in &pairs {
671        entries.push_str("      - \"");
672        // Escape for a YAML double-quoted scalar so a `\` or `"` in the
673        // value can't break out of the entry. Hostnames/IPs shouldn't
674        // contain these, but the `host:` half comes from user-authored
675        // cluster.yml, so don't trust it into a quoted string unescaped.
676        for ch in pair.chars() {
677            match ch {
678                '\\' => entries.push_str("\\\\"),
679                '"' => entries.push_str("\\\""),
680                _ => entries.push(ch),
681            }
682        }
683        entries.push_str("\"\n");
684    }
685
686    // extra_hosts is per-service in compose; apply to every
687    // cluster-capable service so the same override file works
688    // regardless of which one the dispatch lands on.
689    let overlay = format!(
690        "# Generated by fdl-cli (cluster mode) — DO NOT EDIT BY HAND.\n\
691         # Regenerated on every `fdl @cluster ...` invocation.\n\
692         services:\n\
693         \x20\x20cuda:\n\
694         \x20\x20\x20\x20extra_hosts:\n{entries}\
695         \x20\x20dev:\n\
696         \x20\x20\x20\x20extra_hosts:\n{entries}\
697         \x20\x20bench:\n\
698         \x20\x20\x20\x20extra_hosts:\n{entries}",
699    );
700
701    let overlay_path = project_root.join(".fdl-cluster-overlay.yml");
702    if let Err(e) = std::fs::write(&overlay_path, overlay) {
703        eprintln!(
704            "fdl: warning: failed to write cluster compose overlay at {:?}: {e} \
705             (continuing without --add-host injection — remote hostnames \
706             may not resolve inside the container)",
707            overlay_path
708        );
709        return String::new();
710    }
711
712    // base docker-compose.yml first, then our overlay second, so the
713    // overlay's extra_hosts merges into the base service definitions.
714    // Quoted: this string is spliced into an `sh -c` command line, so a
715    // project path with a space would otherwise shatter the parse.
716    format!(
717        " -f docker-compose.yml -f {}",
718        crate::util::shell::posix_quote(&overlay_path.display().to_string())
719    )
720}
721
722/// Hex-encode raw bytes (lowercase, no separators). Companion to the
723/// library's `hex_decode` in `flodl::distributed::cluster`. Kept here
724/// so `prepare_cluster_env` doesn't pull in a flodl runtime dep.
725pub fn hex_encode(bytes: &[u8]) -> String {
726    const TABLE: &[u8; 16] = b"0123456789abcdef";
727    let mut s = String::with_capacity(bytes.len() * 2);
728    for &b in bytes {
729        s.push(TABLE[(b >> 4) as usize] as char);
730        s.push(TABLE[(b & 0x0F) as usize] as char);
731    }
732    s
733}
734
735/// Resolve the controller's OS user name. Used to pre-populate
736/// [`ENV_HOST_USER`] before docker spawn, so the launcher inside the
737/// container can default `ssh -l <user>` to the host's identity.
738/// Falls through `USER` then `whoami`; `None` when both fail — the
739/// caller then leaves [`ENV_HOST_USER`] unset and the launcher omits
740/// `-l` entirely, so ssh applies its own defaults (`ssh_config User`
741/// directives, the effective uid). Never fabricate a name: a made-up
742/// `-l unknown-user` produced a bare "Permission denied (publickey)"
743/// with no hint the username was invented.
744pub fn resolve_local_user() -> Option<String> {
745    resolve_user_from(
746        std::env::var("USER").ok().as_deref(),
747        Command::new("whoami").output().ok().and_then(|out| {
748            if out.status.success() {
749                String::from_utf8(out.stdout).ok()
750            } else {
751                None
752            }
753        }),
754    )
755}
756
757/// Pure core of [`resolve_local_user`] (unit-testable without env
758/// mutation): first non-empty of the `USER` value and the `whoami`
759/// output, both trimmed.
760fn resolve_user_from(user_env: Option<&str>, whoami_out: Option<String>) -> Option<String> {
761    if let Some(s) = user_env {
762        let s = s.trim();
763        if !s.is_empty() {
764            return Some(s.to_string());
765        }
766    }
767    whoami_out
768        .map(|s| s.trim().to_string())
769        .filter(|s| !s.is_empty())
770}
771
772/// Resolve the local OS hostname. Used by `gpus::synthesize_local_cluster`
773/// (the `--gpus` single-host shorthand) and by `prebuild` to skip the
774/// controller from the remote-host fan-out. Test/override seam via
775/// [`ENV_HOST_OVERRIDE`]; falls back to the `hostname(1)` command.
776pub fn resolve_local_hostname() -> String {
777    if let Ok(s) = std::env::var(ENV_HOST_OVERRIDE) {
778        let s = s.trim().to_string();
779        if !s.is_empty() {
780            return s;
781        }
782    }
783    Command::new("hostname")
784        .output()
785        .ok()
786        .and_then(|out| {
787            if out.status.success() {
788                String::from_utf8(out.stdout)
789                    .ok()
790                    .map(|s| s.trim().to_string())
791                    .filter(|s| !s.is_empty())
792            } else {
793                None
794            }
795        })
796        .unwrap_or_else(|| "unknown-host".to_string())
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::util::test_env::env_lock;
803
804    #[test]
805    fn remote_gpu_counts_parse_as_a_vendor_pair() {
806        assert_eq!(parse_gpu_counts("2 0"), Some((2, 0)));
807        assert_eq!(parse_gpu_counts(" 0   8 "), Some((0, 8)));
808        // A host answering with anything else must be a loud parse
809        // failure, never a silent zero that reads as "no GPUs".
810        assert_eq!(parse_gpu_counts("2"), None);
811        assert_eq!(parse_gpu_counts(""), None);
812        assert_eq!(parse_gpu_counts("bash: nvidia-smi: not found"), None);
813        // A remote profile script that prints must not shift the fields:
814        // the counts are the last line, not the first two tokens.
815        assert_eq!(parse_gpu_counts("Welcome to node 7\n0 8"), Some((0, 8)));
816        assert_eq!(parse_gpu_counts("2 0\n"), Some((2, 0)));
817    }
818
819    #[test]
820    fn a_workers_rank_count_follows_the_vendor_it_declares() {
821        // The bug this guards: the probe counted nvidia-smi only, so an
822        // AMD worker with `local_devices: all` probed 0 and aborted the
823        // fan-out, quoting a tool that host does not have.
824        assert_eq!(
825            pick_worker_count(0, 8, Some("precompiled/rocm70"), "h"),
826            Ok(8)
827        );
828        assert_eq!(
829            pick_worker_count(2, 0, Some("precompiled/cu128"), "h"),
830            Ok(2)
831        );
832        // A declared arch outranks the other vendor's cards being present.
833        assert_eq!(
834            pick_worker_count(2, 8, Some("precompiled/rocm71"), "h"),
835            Ok(8)
836        );
837        assert_eq!(
838            pick_worker_count(2, 8, Some("builds/sm61-sm120"), "h"),
839            Ok(2)
840        );
841    }
842
843    #[test]
844    fn an_undeclared_single_vendor_host_still_resolves() {
845        assert_eq!(pick_worker_count(4, 0, None, "h"), Ok(4));
846        assert_eq!(pick_worker_count(0, 4, None, "h"), Ok(4));
847        assert_eq!(pick_worker_count(0, 0, None, "h"), Ok(0));
848        // Both vendors and nothing declared: undecidable HERE (the
849        // controller cannot know which build that box loads), so it must
850        // say so rather than assign ranks to devices nobody addresses.
851        let err = pick_worker_count(2, 8, None, "mixed-host").unwrap_err();
852        assert!(err.contains("2 NVIDIA and 8 AMD"), "got {err}");
853        assert!(err.contains("arch:"), "got {err}");
854    }
855
856    #[test]
857    fn net_timeout_scale_validation_mirrors_library_rule() {
858        // Pure core — no env mutation needed.
859        assert!(validate_net_timeout_scale_value(None).is_ok());
860        assert!(validate_net_timeout_scale_value(Some("3")).is_ok());
861        assert!(validate_net_timeout_scale_value(Some("0.1")).is_ok());
862        assert!(validate_net_timeout_scale_value(Some(" 2.0 ")).is_ok());
863        assert!(validate_net_timeout_scale_value(Some("0.05")).is_err());
864        assert!(validate_net_timeout_scale_value(Some("-1")).is_err());
865        assert!(validate_net_timeout_scale_value(Some("inf")).is_err());
866        assert!(validate_net_timeout_scale_value(Some("abc")).is_err());
867    }
868
869    #[test]
870    fn resolve_user_never_fabricates() {
871        // Pure core — no env mutation needed.
872        assert_eq!(resolve_user_from(Some("fab"), None).as_deref(), Some("fab"));
873        assert_eq!(
874            resolve_user_from(Some(" fab \n"), None).as_deref(),
875            Some("fab")
876        );
877        // Empty USER falls through to whoami.
878        assert_eq!(
879            resolve_user_from(Some(""), Some("who\n".into())).as_deref(),
880            Some("who"),
881        );
882        assert_eq!(
883            resolve_user_from(None, Some("who".into())).as_deref(),
884            Some("who")
885        );
886        // Double failure -> None (never a fabricated "unknown-user").
887        assert_eq!(resolve_user_from(None, None), None);
888        assert_eq!(resolve_user_from(Some("  "), Some("  ".into())), None);
889    }
890
891    #[test]
892    fn should_dispatch_returns_false_when_cluster_json_set() {
893        let _guard = env_lock();
894        // SAFETY: serialized via env_lock() above.
895        unsafe {
896            std::env::set_var(ENV_CLUSTER_JSON, "deadbeef");
897        }
898        let yaml = "\
899cluster:
900  controller:
901    host: 127.0.0.1
902    port: 29500
903    path: /opt/flodl
904  workers:
905    - host: solo
906      local_devices: [0]
907      nccl_socket_ifname: lo
908      path: /opt/flodl
909commands:
910  x: { cluster: true, run: \"echo hi\" }
911";
912        let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
913        assert!(
914            !should_dispatch(&project, &[Some(true)]),
915            "recursion guard: must return false when FLODL_INTERNAL_CLUSTER_JSON is set"
916        );
917        unsafe {
918            std::env::remove_var(ENV_CLUSTER_JSON);
919        }
920    }
921
922    #[test]
923    fn should_dispatch_delegates_when_env_unset() {
924        let _guard = env_lock();
925        unsafe {
926            std::env::remove_var(ENV_CLUSTER_JSON);
927        }
928        let yaml = "\
929cluster:
930  controller:
931    host: 127.0.0.1
932    port: 29500
933    path: /opt/flodl
934  workers:
935    - host: solo
936      local_devices: [0]
937      nccl_socket_ifname: lo
938      path: /opt/flodl
939commands:
940  x: { run: \"echo hi\" }
941";
942        let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
943        assert!(!should_dispatch(&project, &[None]));
944        assert!(should_dispatch(&project, &[Some(true)]));
945    }
946
947    #[test]
948    fn hex_encode_matches_library() {
949        // Well-known mappings; library's flodl::distributed::cluster::hex_decode
950        // is the round-trip partner.
951        assert_eq!(hex_encode(b""), "");
952        assert_eq!(hex_encode(&[0x00]), "00");
953        assert_eq!(hex_encode(&[0xff]), "ff");
954        assert_eq!(hex_encode(&[0x0f, 0xa0]), "0fa0");
955        assert_eq!(hex_encode(b"hi"), "6869");
956    }
957
958    #[test]
959    fn prepare_cluster_env_sets_required_vars() {
960        let _guard = env_lock();
961        // Clear env first so we observe what prepare_cluster_env sets.
962        unsafe {
963            std::env::remove_var(ENV_FULL_CLUSTER_JSON);
964            std::env::remove_var(ENV_FDL_CMD);
965            std::env::remove_var(ENV_FDL_ENV);
966        }
967        let yaml = "\
968cluster:
969  controller:
970    host: 127.0.0.1
971    port: 29500
972    path: /opt/flodl
973  workers:
974    - host: solo
975      local_devices: [0]
976      nccl_socket_ifname: lo
977      path: /opt/flodl
978commands:
979  train: { cluster: true, run: \"true\" }
980";
981        let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
982        let cluster = project.cluster.as_ref().unwrap();
983        prepare_cluster_env(cluster, Some("cluster"), "train").expect("prepare OK");
984
985        assert!(!std::env::var(ENV_FULL_CLUSTER_JSON).unwrap().is_empty());
986        assert_eq!(std::env::var(ENV_FDL_CMD).unwrap(), "train");
987        assert_eq!(std::env::var(ENV_FDL_ENV).unwrap(), "cluster");
988
989        // Verify the full envelope round-trips back to the canonical JSON.
990        let hex = std::env::var(ENV_FULL_CLUSTER_JSON).unwrap();
991        // Decode and parse it as JSON.
992        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
993
994        unsafe {
995            std::env::remove_var(ENV_FULL_CLUSTER_JSON);
996            std::env::remove_var(ENV_FDL_CMD);
997            std::env::remove_var(ENV_FDL_ENV);
998        }
999    }
1000
1001    #[test]
1002    fn prepare_cluster_env_skips_fdl_env_when_blank() {
1003        let _guard = env_lock();
1004        unsafe {
1005            std::env::remove_var(ENV_FDL_ENV);
1006        }
1007        let yaml = "\
1008cluster:
1009  controller:
1010    host: 127.0.0.1
1011    port: 29500
1012    path: /opt/flodl
1013  workers:
1014    - host: solo
1015      local_devices: [0]
1016      nccl_socket_ifname: lo
1017      path: /opt/flodl
1018commands:
1019  train: { cluster: true, run: \"true\" }
1020";
1021        let project: ProjectConfig = serde_yaml_ng::from_str(yaml).unwrap();
1022        let cluster = project.cluster.as_ref().unwrap();
1023        // None overlay → no FDL_ENV var set.
1024        prepare_cluster_env(cluster, None, "train").unwrap();
1025        assert!(std::env::var_os(ENV_FDL_ENV).is_none());
1026
1027        // Empty overlay → also no FDL_ENV var.
1028        prepare_cluster_env(cluster, Some("   "), "train").unwrap();
1029        assert!(std::env::var_os(ENV_FDL_ENV).is_none());
1030
1031        unsafe {
1032            std::env::remove_var(ENV_FULL_CLUSTER_JSON);
1033            std::env::remove_var(ENV_FDL_CMD);
1034        }
1035    }
1036
1037    #[test]
1038    fn select_preferred_ip_prefers_non_loopback() {
1039        use std::net::IpAddr;
1040        // The Debian/Ubuntu /etc/hosts shape we have to handle:
1041        // 127.0.1.1 comes back FIRST, the routable LAN/bridge IP second.
1042        let ips: Vec<IpAddr> = vec![
1043            "127.0.1.1".parse().unwrap(),
1044            "192.168.122.1".parse().unwrap(),
1045        ];
1046        let (ip, only_loopback) = select_preferred_ip(ips);
1047        assert_eq!(ip.as_deref(), Some("192.168.122.1"));
1048        assert!(!only_loopback);
1049    }
1050
1051    #[test]
1052    fn select_preferred_ip_falls_back_to_loopback_with_flag() {
1053        use std::net::IpAddr;
1054        // Misconfig case: only loopback resolves. Return it so the
1055        // caller still has SOMETHING, but flip the flag so the caller
1056        // warns.
1057        let ips: Vec<IpAddr> = vec!["127.0.1.1".parse().unwrap(), "::1".parse().unwrap()];
1058        let (ip, only_loopback) = select_preferred_ip(ips);
1059        assert_eq!(ip.as_deref(), Some("127.0.1.1"));
1060        assert!(only_loopback);
1061    }
1062
1063    #[test]
1064    fn select_preferred_ip_empty_iterator() {
1065        let (ip, only_loopback) = select_preferred_ip(std::iter::empty());
1066        assert!(ip.is_none());
1067        assert!(!only_loopback);
1068    }
1069
1070    #[test]
1071    fn select_preferred_ip_skips_ipv6_loopback() {
1072        use std::net::IpAddr;
1073        // IPv6 loopback (::1) must be skipped just like 127.x.
1074        let ips: Vec<IpAddr> = vec!["::1".parse().unwrap(), "10.0.0.5".parse().unwrap()];
1075        let (ip, only_loopback) = select_preferred_ip(ips);
1076        assert_eq!(ip.as_deref(), Some("10.0.0.5"));
1077        assert!(!only_loopback);
1078    }
1079
1080    #[test]
1081    fn prepare_cluster_env_validates_cluster() {
1082        let _guard = env_lock();
1083        // Empty controller.host → validate() fails → prepare_cluster_env errors.
1084        let cluster = ClusterConfig {
1085            controller: crate::config::ClusterController {
1086                host: String::new(),
1087                port: 1337,
1088                path: String::new(),
1089                docker: None,
1090                arch: None,
1091                data_path: None,
1092                join: None,
1093            },
1094            workers: Vec::new(),
1095            env: std::collections::BTreeMap::new(),
1096            gpu_ram_share: None,
1097        };
1098        let err = prepare_cluster_env(&cluster, None, "train").unwrap_err();
1099        assert!(err.contains("controller.host"), "got: {err}");
1100    }
1101
1102    /// Workers reached via `~/.ssh/config` aliases (or any setup where
1103    /// the YAML's `host:` is just a label, not a DNS-resolvable name)
1104    /// carry an explicit `ssh.target` in the worker block. In that case
1105    /// the controller's NSS does not need to know the `host:` value —
1106    /// the connection uses `ssh.target` — so the "did not resolve"
1107    /// warning is noise and gets suppressed.
1108    ///
1109    /// Uses `nonexistent.invalid.` (RFC 2606 reserved) to guarantee
1110    /// `getaddrinfo` returns Err on any system regardless of local DNS
1111    /// or /etc/hosts content.
1112    #[test]
1113    fn resolve_cluster_extra_hosts_suppresses_warning_when_ssh_target_explicit() {
1114        use crate::config::{ClusterController, ClusterWorker, LocalDevices, SshConfig};
1115        let cluster = ClusterConfig {
1116            controller: ClusterController {
1117                host: "127.0.0.1".into(),
1118                port: 1337,
1119                path: "/tmp".into(),
1120                docker: None,
1121                arch: None,
1122                data_path: None,
1123                join: None,
1124            },
1125            workers: vec![ClusterWorker {
1126                host: "nonexistent.invalid.".into(),
1127                ranks: vec![0],
1128                local_devices: LocalDevices::Explicit(vec![0]),
1129                nccl_socket_ifname: "lo".into(),
1130                path: "/tmp".into(),
1131                ssh: Some(SshConfig {
1132                    target: Some("127.0.0.1".into()),
1133                    ..SshConfig::default()
1134                }),
1135                tunnel: false,
1136                arch: None,
1137                data_path: None,
1138                gpu_ram_share: None,
1139                docker: None,
1140                env: std::collections::BTreeMap::new(),
1141            }],
1142            env: std::collections::BTreeMap::new(),
1143            gpu_ram_share: None,
1144        };
1145        let (_hosts, warnings) = resolve_cluster_extra_hosts(&cluster);
1146        assert!(
1147            warnings.is_empty(),
1148            "explicit ssh.target should suppress the resolution warning, \
1149             got warnings: {warnings:?}"
1150        );
1151    }
1152
1153    /// Mirror of the suppression test: without `ssh.target` the warning
1154    /// must still fire (so legitimate misconfigurations stay visible).
1155    #[test]
1156    fn resolve_cluster_extra_hosts_warns_when_ssh_target_absent() {
1157        use crate::config::{ClusterController, ClusterWorker, LocalDevices};
1158        let cluster = ClusterConfig {
1159            controller: ClusterController {
1160                host: "127.0.0.1".into(),
1161                port: 1337,
1162                path: "/tmp".into(),
1163                docker: None,
1164                arch: None,
1165                data_path: None,
1166                join: None,
1167            },
1168            workers: vec![ClusterWorker {
1169                host: "nonexistent.invalid.".into(),
1170                ranks: vec![0],
1171                local_devices: LocalDevices::Explicit(vec![0]),
1172                nccl_socket_ifname: "lo".into(),
1173                path: "/tmp".into(),
1174                ssh: None,
1175                tunnel: false,
1176                arch: None,
1177                data_path: None,
1178                gpu_ram_share: None,
1179                docker: None,
1180                env: std::collections::BTreeMap::new(),
1181            }],
1182            env: std::collections::BTreeMap::new(),
1183            gpu_ram_share: None,
1184        };
1185        let (_hosts, warnings) = resolve_cluster_extra_hosts(&cluster);
1186        assert!(
1187            warnings.iter().any(|w| w.contains("did not resolve")),
1188            "missing ssh.target should keep the warning, got warnings: {warnings:?}"
1189        );
1190    }
1191
1192    /// A farm overlay declares a join window; a command that is not a
1193    /// cluster command will not open it and trains here instead. The run
1194    /// looks entirely normal, which is why this needs saying out loud.
1195    #[test]
1196    fn a_declared_join_window_that_no_command_opens_is_called_out() {
1197        let with_window = "\
1198cluster:
1199  controller:
1200    host: 127.0.0.1
1201    port: 1337
1202    path: /opt/flodl
1203    join:
1204      discovery: true
1205      token: aaaabbbbccccddddaaaabbbbccccdddd
1206  workers: []
1207";
1208        let project: ProjectConfig = serde_yaml_ng::from_str(with_window).unwrap();
1209        let hint = unused_join_window_hint(&project, "train").expect("a window nobody opens");
1210        assert!(hint.contains("train"), "names the command: {hint}");
1211        assert!(hint.contains("cluster:"), "names the fix: {hint}");
1212
1213        // A roster-style cluster block without a discovery window has
1214        // nothing to miss: fan-out is the command's own business.
1215        let no_window = "\
1216cluster:
1217  controller:
1218    host: 127.0.0.1
1219    port: 1337
1220    path: /opt/flodl
1221  workers: []
1222";
1223        let project: ProjectConfig = serde_yaml_ng::from_str(no_window).unwrap();
1224        assert!(unused_join_window_hint(&project, "train").is_none());
1225    }
1226}