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}