Skip to main content

flodl_cli/config/
cluster.rs

1//! DDP + cluster configuration types: DdpConfig, SpeedHint,
2//! TrainingConfig, OutputConfig, ClusterConfig, ClusterController,
3//! LocalDevices, ClusterWorker + the `ClusterConfig` impl block.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// DDP configuration. Maps 1:1 to flodl DdpConfig / DdpRunConfig.
9#[derive(Debug, Clone, Default, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct DdpConfig {
12    pub mode: Option<String>,
13    pub policy: Option<String>,
14    pub backend: Option<String>,
15    /// "auto" or integer.
16    pub anchor: Option<serde_json::Value>,
17    pub max_anchor: Option<u32>,
18    pub overhead_target: Option<f64>,
19    pub divergence_threshold: Option<f64>,
20    /// null (unlimited) or integer.
21    pub max_batch_diff: Option<serde_json::Value>,
22    pub speed_hint: Option<SpeedHint>,
23    pub partition_ratios: Option<Vec<f64>>,
24    /// "auto" or bool.
25    pub progressive: Option<serde_json::Value>,
26    pub max_grad_norm: Option<f64>,
27    pub lr_scale_ratio: Option<f64>,
28    pub snapshot_timeout: Option<u32>,
29    pub checkpoint_every: Option<u32>,
30    pub timeline: Option<bool>,
31}
32
33#[derive(Debug, Clone, Default, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct SpeedHint {
36    pub slow_rank: usize,
37    pub ratio: f64,
38}
39
40/// Training scalars.
41#[derive(Debug, Clone, Default, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TrainingConfig {
44    pub epochs: Option<u32>,
45    pub batch_size: Option<u32>,
46    pub batches_per_epoch: Option<u32>,
47    pub lr: Option<f64>,
48    pub seed: Option<u64>,
49}
50
51/// Output settings.
52#[derive(Debug, Clone, Default, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct OutputConfig {
55    pub dir: Option<String>,
56    pub timeline: Option<bool>,
57    pub monitor: Option<u16>,
58}
59
60// ── Cluster topology (multi-host DDP) ───────────────────────────────────
61
62/// Default port for the controller's rendezvous bind. Overridable via
63/// `cluster.controller.port:` in fdl.cluster.yml.
64pub const DEFAULT_CONTROLLER_PORT: u16 = 1337;
65
66fn default_controller_port() -> u16 {
67    DEFAULT_CONTROLLER_PORT
68}
69
70/// Cluster topology, parsed from the `cluster:` block at the project
71/// root. Two role-separated sub-blocks:
72///
73/// - `controller`: the orchestrator host fdl-cli runs on. Holds the
74///   rendezvous bind point (`host`/`port`) plus the controller-local
75///   fields needed for pre-flight build and the ClusterController TCP
76///   listener. The controller is NOT a NCCL rank.
77/// - `workers`: every rank-carrying host. Each entry binds one or more
78///   global ranks and their CUDA device indices.
79///
80/// This is the controller-side full topology; per-worker slim
81/// envelopes are derived from it and shipped to each node, where the
82/// library reads them via `flodl::distributed::LocalCluster::from_env`.
83/// Launcher-only fields (`ssh:`) are not propagated to the envelope.
84///
85/// The library re-validates after reading; this validation runs earlier
86/// so errors surface before `fdl-cli` opens any SSH connection.
87#[derive(Debug, Clone, Deserialize, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct ClusterConfig {
90    pub controller: ClusterController,
91    pub workers: Vec<ClusterWorker>,
92    /// Cluster-scope env vars exported into every rank child on every
93    /// worker. Mapping `NAME: VALUE` (string→string). Use for tuning
94    /// the launcher itself shouldn't hardcode — e.g. on the Pascal-
95    /// under-VFIO rig, set `NCCL_P2P_DISABLE: "1"` +
96    /// `NCCL_SHM_DISABLE: "1"` so NCCL falls back to socket transport
97    /// (the direct-IPC transports fail under VFIO on consumer Pascal).
98    /// Worker-scope [`ClusterWorker::env`] takes precedence for matching
99    /// keys.
100    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
101    pub env: std::collections::BTreeMap<String, String>,
102    /// Cluster-scope default for the integrated-GPU host-RAM share: the
103    /// fraction of `MemTotal` an APU host's GPU aperture claims (the
104    /// library's `gpu_ram_share` knob). Discrete-GPU hosts ignore it,
105    /// which is what makes a fleet-wide default legal on a mixed fleet;
106    /// the case it exists for is a farm of identical APU boxes, where
107    /// one line here covers walk-ins the controller never enumerates.
108    /// Per-worker [`ClusterWorker::gpu_ram_share`] overrides it, and a
109    /// walk-in's own `join.gpu_ram_share:` overrides both (host truth
110    /// wins). Non-negative fraction of `MemTotal` (above 1.0 is legal
111    /// where the platform under-states the aperture); unset ships
112    /// nothing.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub gpu_ram_share: Option<f64>,
115}
116
117/// Controller-side cluster config. Holds the rendezvous bind point and
118/// pre-flight build context. The controller is the orchestrator host
119/// fdl-cli runs on; it is NOT a NCCL rank itself.
120///
121/// Rejected fields (via `deny_unknown_fields`): `ranks`,
122/// `local_devices`, `ssh*` (controller is local to fdl-cli),
123/// per-controller `env` (use cluster-scope `env:` instead) — and any
124/// mistyped key.
125#[derive(Debug, Clone, Deserialize, Serialize)]
126#[serde(deny_unknown_fields)]
127pub struct ClusterController {
128    /// Bind address. Used as the rendezvous endpoint workers dial.
129    /// What was `cluster.controller.host` in the pre-Refactor-2 schema.
130    pub host: String,
131    /// Bind port. What was `cluster.controller.port` in the pre-Refactor-2
132    /// schema. Defaults to [`DEFAULT_CONTROLLER_PORT`] when omitted.
133    #[serde(default = "default_controller_port")]
134    pub port: u16,
135    /// Controller's view of the shared project root. Used by the pre-
136    /// flight build phase to drive cargo invocations from the
137    /// controller's filesystem perspective; the remote-side path (each
138    /// worker's [`ClusterWorker::path`]) is used at runtime via SSH.
139    ///
140    /// On homogeneous-mount rigs the controller's view equals every
141    /// worker's view; on heterogeneous rigs (different mount points
142    /// per host), the controller's value diverges (e.g. the controller
143    /// sees `/opt/flodl` while a VM worker sees `/mnt/flodl`).
144    pub path: String,
145    /// Docker compose service the controller runs the pre-flight build
146    /// inside (e.g. `cuda`, `dev`). Optional; affects build context
147    /// only.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub docker: Option<String>,
150    /// libtorch variant subpath under `<path>/libtorch/` for the
151    /// pre-flight build (e.g. `precompiled/cu128`,
152    /// `builds/sm61-sm120`). When unset, fdl-cli falls back to the
153    /// controller's project-root `.active` file.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub arch: Option<String>,
156    /// Dataset source root visible to the controller: training data,
157    /// model checkpoints and per-rank logs. Usually a filesystem
158    /// reachable at the same logical path on every node, which is the
159    /// recommended shape; a node-local directory is legal, and then
160    /// each host holds its own copy. When absent, the convention
161    /// default [`DEFAULT_DATA_PATH`] applies to the checks (`fdl probe`,
162    /// pre-flight) but is NOT shipped to a run — see
163    /// [`ClusterWorker::data_path`].
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub data_path: Option<String>,
166    /// Join-window quorum knobs (dial-in membership). fdl-cli carries
167    /// the block through the launcher envelope verbatim; flodl owns
168    /// validation and the capacity-derived defaults.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub join: Option<ClusterJoin>,
171}
172
173/// `controller.join:` sub-block — per-field overrides for the
174/// membership window. All fields optional; unset keeps flodl's fan-out
175/// defaults (quorum = early-close target = configured capacity, window
176/// 300s, hard cap 600s, pre-shared-salt admission).
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct ClusterJoin {
180    /// Quorum in ranks — the run cannot start below it.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub min_rank_start: Option<usize>,
183    /// Join window in seconds; quorum reached early does NOT close it.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub join_timeout: Option<u64>,
186    /// Early-close target in ranks: the window closes the moment this
187    /// many are in.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub target_ranks: Option<usize>,
190    /// Hard cap in seconds: quorum still unmet when it expires fails
191    /// the run loudly.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub max_join_timeout: Option<u64>,
194    /// Accept joins without pre-shared-salt authentication on a
195    /// non-loopback bind (loudly warned by flodl).
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub open_admission: Option<bool>,
198    /// Roster-free formation: the join window alone defines the world,
199    /// so `workers:` may be empty (walk-ins self-register via `fdl
200    /// join`). Requires an explicit `min_rank_start` — there is no
201    /// configured capacity to derive the quorum from.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub discovery: Option<bool>,
204    /// Pre-shared session salt, hex (32 chars / 16 bytes). Injected at
205    /// fleet-create time and presented by walk-ins as their join
206    /// credential; forces authenticated admission even behind sshd.
207    /// Mutually exclusive with `open_admission: true`.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub token: Option<String>,
210    /// Discovery-only: bind the controller loopback-only so every
211    /// walk-in must arrive through an sshd-carried forward
212    /// (reachability = authentication). Requires a CPU averaging mode.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub tunnel_only: Option<bool>,
215    /// Who closes the window once quorum is met: `auto` (clock —
216    /// target/expiry, the default), `manual` (only the operator, via
217    /// `fdl start`; refuses `target_ranks`), or `hybrid` (clock, and
218    /// the operator may fire earlier). flodl owns the semantics; fdl
219    /// validates the value so a typo dies before any host is touched.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub start: Option<String>,
222}
223
224impl ClusterController {
225    /// Effective shared-data path: `data_path` if set, else
226    /// [`DEFAULT_DATA_PATH`].
227    pub fn effective_data_path(&self) -> &str {
228        self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
229    }
230}
231
232/// Top-level `join:` block — the worker-side counterpart of
233/// `controller.join:`. Carries defaults for `fdl join` so a golden
234/// image (or a systemd unit) can bake the whole dial-in recipe into
235/// config and run bare `fdl join`. Every field is overridable on the
236/// command line; flags win over this block.
237#[derive(Debug, Clone, Default, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct WorkerJoin {
240    /// Controller mux address, `host[:port]` (default port 1337).
241    /// Under `ssh:` this is the address as seen FROM the ssh host —
242    /// usually loopback, the guardrailed-sshd-on-the-controller-box
243    /// case.
244    #[serde(default)]
245    pub controller: Option<String>,
246    /// SSH tunnel hop: `fdl join` brings up a local `-L` forward of
247    /// the controller mux port through this host and dials loopback.
248    /// Same shape as a worker's `ssh:` sub-block (target / port /
249    /// user / identity_file / options).
250    #[serde(default)]
251    pub ssh: Option<SshConfig>,
252    /// Pre-shared session credential (the run's
253    /// `controller.join.token`), hex. Unset = open admission (the
254    /// controller hands the salt out in the accept reply).
255    #[serde(default)]
256    pub token: Option<String>,
257    /// Training binary to run in agent role, as a path on this box: use
258    /// it as given. Mutually exclusive with `source:`, and one of the
259    /// two is required — the binary IS the protocol (it dials, joins,
260    /// and spawns this host's relay + rank children itself), so `fdl
261    /// join` cannot default it.
262    ///
263    /// This is also the escape hatch for a box whose owner wants the
264    /// last word on what it compiles and runs: a declared binary wins
265    /// over any source a controller would hand it.
266    #[serde(default)]
267    pub bin: Option<String>,
268    /// Build the training binary here instead of naming one. The box
269    /// fetches the tree to local disk and compiles it against its OWN
270    /// libtorch, which is what makes the ABI match by construction
271    /// rather than by manifest discipline.
272    #[serde(default)]
273    pub source: Option<WorkerSource>,
274    /// libtorch variant to acquire before building or running:
275    /// `auto`, `cpu`, `cu126`, `cu128`, `rocm7.0`. It lands under
276    /// `~/.flodl/libtorch/` (never the project tree, which on a walk-in
277    /// is often a read-only mount) and becomes this box's active
278    /// variant. Unset leaves the box on whatever it already has.
279    ///
280    /// `auto` is the fleet value: it routes on the devices THIS box has,
281    /// so one golden image serves NVIDIA and AMD instances.
282    #[serde(default)]
283    pub libtorch: Option<String>,
284    /// Logical host name presented in the join hello (default: this
285    /// machine's hostname).
286    #[serde(default)]
287    pub host: Option<String>,
288    /// GPU device indices to offer, one rank each (default: every
289    /// device detection sees for the training build's vendor).
290    #[serde(default)]
291    pub devices: Option<Vec<u8>>,
292    /// Keep dialing across runs: when the agent exits (run finished,
293    /// controller gone, no window open yet) `fdl join` backs off and
294    /// re-dials instead of exiting. The systemd / golden-image mode.
295    #[serde(default)]
296    pub persist: bool,
297    /// Dataset source root on THIS box — a local path, the same role
298    /// `cluster.workers[].data_path` plays for a fan-out host. `fdl
299    /// join` verifies it before dialing and ships it to this host's
300    /// ranks, so the training binary needs no data flag. Unset ships
301    /// nothing: the binary keeps its own default.
302    ///
303    /// With `data_source:` set this is the MOUNTPOINT, defaulting to
304    /// [`DEFAULT_DATA_PATH`].
305    #[serde(default)]
306    pub data_path: Option<String>,
307    /// Transport that establishes `data_path:` when it is not already
308    /// there, `<scheme>://<target>`. One scheme ships today:
309    ///
310    /// ```text
311    /// sshfs://[user@]host[:port]/abs/path
312    /// sshfs://[user@]host:/abs/path        # the scp spelling, same thing
313    /// ```
314    ///
315    /// A source already mounted by provisioning needs nothing here —
316    /// name its path in `data_path:` and leave this unset. The mount
317    /// goes up READ-ONLY: a rank reads the source root and never
318    /// writes it, so the kernel, not a convention, enforces that.
319    #[serde(default)]
320    pub data_source: Option<String>,
321    /// Integrated-GPU host-RAM share for THIS box: the fraction of
322    /// `MemTotal` its GPU aperture claims (the library's
323    /// `gpu_ram_share` knob; discrete GPUs ignore it). Same role as a
324    /// fan-out host's `cluster.workers[].gpu_ram_share`, riding the
325    /// same envelope road: the agent writes it into this host's block
326    /// at join time, where it overrides any cluster-scope default the
327    /// controller stamped. Non-negative fraction of `MemTotal` (above
328    /// 1.0 is legal where the platform under-states the aperture);
329    /// unset ships nothing.
330    #[serde(default)]
331    pub gpu_ram_share: Option<f64>,
332    /// Probe the training binary for its model signature before each
333    /// dial (default: on). The probe re-runs the binary with
334    /// `FLODL_INTERNAL_MODEL_SIG_PROBE` set; it builds its model on
335    /// CPU, prints the signature and exits, and admission can then
336    /// refuse a box building a different model without costing the
337    /// run. `false` skips it — the formation-time check remains — for
338    /// a binary whose startup is too heavy to run twice per dial, or
339    /// one built against a flodl that predates the probe (which would
340    /// otherwise run its whole `main` until the probe timeout kills
341    /// it).
342    #[serde(default)]
343    pub sig_probe: Option<bool>,
344    /// Arguments for the training binary (the binary's own training
345    /// flags). A `--` tail on the command line replaces this list.
346    #[serde(default)]
347    pub args: Vec<String>,
348}
349
350/// `join.source:` — build the training binary on this box.
351///
352/// A tree always lands on LOCAL disk before it is built, whatever the
353/// transport: cargo fingerprints by stat'ing every source file on every
354/// invocation, so compiling over a network mount pays that latency
355/// thousands of times, and the attribute caching that would hide the
356/// latency makes cargo serve a stale binary. The fetch preserves mtimes,
357/// which is what keeps the loop incremental rather than a cold rebuild
358/// wearing an incremental costume.
359#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub struct WorkerSource {
362    /// Transport plus location. Three ship:
363    ///
364    /// ```text
365    /// file:///abs/path                      a directory on this box (a mount, a disk)
366    /// rsync://[user@]host[:port]:/abs/path  a working tree over ssh
367    /// git+https://host/owner/repo#<ref>     a pinned checkout (also git+ssh://)
368    /// ```
369    ///
370    /// rsync is the one that carries UNCOMMITTED work, which is what a
371    /// training crate living in no repo at all needs. `git+` wants a ref
372    /// (`#<tag|branch|sha>`) because a default branch floats, and a
373    /// floating ref is not a pin.
374    pub from: String,
375    /// Project directory inside the fetched tree (default: its root).
376    /// Governs the build and the run both, so it answers "where is the
377    /// project in this tree" exactly once. A path dep pointing outside
378    /// this directory still resolves, which is why the fetched tree has
379    /// to be the dep root and not just the crate.
380    #[serde(default)]
381    pub cwd: Option<String>,
382    /// Build recipe, run as a shell line in `cwd`. Default: `cargo build
383    /// --release`, which is right whenever the crate carries its own
384    /// `Cargo.toml`, lockfile and `rust-toolchain.toml` — the usual case
385    /// for a crate that builds on the operator's own box.
386    ///
387    /// It can be a script committed beside the code (`./ci/node-build.sh`),
388    /// so the recipe travels with the source while its invocation stays
389    /// here. fdl exports what it resolved: `LIBTORCH_PATH`,
390    /// `FDL_GPU_FEATURE` (say `--features "$FDL_GPU_FEATURE"` rather than
391    /// naming a vendor) and `LD_LIBRARY_PATH`.
392    ///
393    /// It re-runs on every dial, so it must be cheap when nothing
394    /// changed. cargo is; a recipe that rebuilds unconditionally is not.
395    #[serde(default)]
396    pub build: Option<String>,
397    /// Built artifact, relative to `cwd` (e.g. `target/release/train`).
398    /// Not guessed: `cargo build` in a workspace member writes to the
399    /// WORKSPACE `target/`, not the member's, so any rule fdl invented
400    /// would be wrong for someone.
401    ///
402    /// Optional because a published tree carries a run manifest that
403    /// names it, and that manifest is the authority when present. Declare
404    /// it here for a box the operator drives by hand.
405    #[serde(default)]
406    pub bin: Option<String>,
407}
408
409/// Top-level `publish:` block — the controller's standing answers for
410/// `fdl publish`, so re-publishing a run is one bare command. Every
411/// field mirrors a flag 1:1 and the FLAG WINS when both are given; a
412/// `--` tail replaces `args:` outright (even an empty tail, because
413/// "explicitly none" must be sayable — the args belong to the RUN).
414///
415/// `--no-build` has no field here ON PURPOSE: a standing config that
416/// silently skips the gate would ship every future typo to the fleet.
417/// The escape hatch stays a per-invocation decision.
418#[derive(Debug, Clone, Default, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct PublishBlock {
421    /// Source to publish, same grammar as `join.source.from:`
422    /// (`file://`, `rsync://`, `git+https://…#<ref>`).
423    #[serde(default)]
424    pub source: Option<String>,
425    /// Built artifact, relative to the project directory — what workers
426    /// run (`--bin`).
427    #[serde(default)]
428    pub bin: Option<String>,
429    /// Project directory inside the tree (`--cwd`; default: its root).
430    #[serde(default)]
431    pub cwd: Option<String>,
432    /// Build recipe for the gate, a shell line (`--build`; default
433    /// `cargo build --release`).
434    #[serde(default)]
435    pub build: Option<String>,
436    /// Served directory (`--to`; default `~/.flodl/run`).
437    #[serde(default)]
438    pub to: Option<String>,
439    /// Identity file for a source pulled over ssh (`--identity`).
440    #[serde(default)]
441    pub identity: Option<String>,
442    /// The run's own arguments — what rank children re-enter the binary
443    /// with. A command-line `--` tail replaces this list.
444    #[serde(default)]
445    pub args: Vec<String>,
446}
447
448/// CUDA device indices on a host. Either explicit (a list of indices) or
449/// the `"all"` shorthand (auto-detect at startup on that host).
450///
451/// `local_devices: all` defers device-index resolution to startup on
452/// whichever host the value lives on. The host uses `gpu_device_count()`
453/// and binds devices `0..ranks.len()` (sequential from index 0). The
454/// detected count must be at least as large as `ranks.len()`, otherwise
455/// rendezvous errors loudly.
456///
457/// `local_devices: [0, 1]` is the explicit form; same as before this
458/// shorthand existed.
459///
460/// Symmetric: works for both the controller host and remote nodes. Pair
461/// with explicit `ranks: [N, N+1, ...]` to define the world topology;
462/// `local_devices` only selects which device indices on this host map to
463/// those ranks.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub enum LocalDevices {
466    /// Auto-detect at startup on this host. Indices `0..ranks.len()` bound.
467    All,
468    /// Explicit list of CUDA device indices, paired positionally with ranks.
469    Explicit(Vec<u8>),
470}
471
472impl LocalDevices {
473    /// True for the auto-detect form (unresolved).
474    pub fn is_all(&self) -> bool {
475        matches!(self, LocalDevices::All)
476    }
477
478    /// Explicit indices as a slice, or `None` for `All`.
479    pub fn as_explicit(&self) -> Option<&[u8]> {
480        match self {
481            LocalDevices::All => None,
482            LocalDevices::Explicit(v) => Some(v.as_slice()),
483        }
484    }
485}
486
487impl Serialize for LocalDevices {
488    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
489        match self {
490            LocalDevices::All => s.serialize_str("all"),
491            LocalDevices::Explicit(v) => v.serialize(s),
492        }
493    }
494}
495
496impl<'de> Deserialize<'de> for LocalDevices {
497    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
498        use serde::de::Error;
499        let v = Value::deserialize(d)?;
500        match v {
501            Value::String(s) if s == "all" => Ok(LocalDevices::All),
502            Value::String(s) => Err(D::Error::custom(format!(
503                "local_devices: expected \"all\" or array of device indices, got string {s:?}"
504            ))),
505            Value::Array(arr) => {
506                let mut out = Vec::with_capacity(arr.len());
507                for (i, item) in arr.iter().enumerate() {
508                    let n = item.as_u64().ok_or_else(|| {
509                        D::Error::custom(format!(
510                            "local_devices[{i}]: expected integer device index, got {item}"
511                        ))
512                    })?;
513                    let d = u8::try_from(n).map_err(|_| {
514                        D::Error::custom(format!(
515                            "local_devices[{i}]: value {n} does not fit in u8"
516                        ))
517                    })?;
518                    out.push(d);
519                }
520                Ok(LocalDevices::Explicit(out))
521            }
522            _ => Err(D::Error::custom(
523                "local_devices: expected \"all\" or array of device indices",
524            )),
525        }
526    }
527}
528
529/// SSH endpoint configuration for a remote worker host.
530///
531/// fdl-cli parser side; mirrors `flodl::distributed::launcher::SshConfig`
532/// shape so the slim envelope round-trips cleanly into the launcher. All
533/// fields optional; absent fields fall back to system ssh defaults or
534/// `~/.ssh/config` rules.
535#[derive(Debug, Clone, Default, Deserialize, Serialize)]
536#[serde(deny_unknown_fields)]
537pub struct SshConfig {
538    /// SSH target hostname / IP / alias. Defaults to the worker's
539    /// `host` field when unset.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub target: Option<String>,
542    /// SSH port (`ssh -p <port>`).
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub port: Option<u16>,
545    /// SSH login user (`ssh -l <user>`). Defaults to the current user
546    /// (or `FLODL_INTERNAL_HOST_USER` from the controller env).
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub user: Option<String>,
549    /// Identity file / private key (`ssh -i <path>`).
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub identity_file: Option<String>,
552    /// Pass-through `-o Key=Value` SSH options (e.g.
553    /// `"ProxyJump=bastion"`, `"StrictHostKeyChecking=no"`). Each entry
554    /// becomes one `-o ...` arg on the spawned `ssh` command, in the
555    /// declared order.
556    #[serde(default, skip_serializing_if = "Vec::is_empty")]
557    pub options: Vec<String>,
558}
559
560/// One worker (a physical host running one or more NCCL ranks).
561#[derive(Debug, Clone, Deserialize, Serialize)]
562#[serde(deny_unknown_fields)]
563pub struct ClusterWorker {
564    /// Hostname / identifier (was `name:` in the pre-Refactor-2 schema).
565    /// Used for /etc/hosts resolution, `--add-host` injection, and as
566    /// the default SSH target (override via `ssh:`). On the worker
567    /// itself, this is the value `hostname` returns (or the
568    /// `FLODL_HOST_NAME`-overridden value).
569    pub host: String,
570    /// Global ranks owned by this worker.
571    ///
572    /// NOT part of the user YAML schema — populated internally by
573    /// [`ClusterConfig::populate_ranks`] from probed device counts
574    /// (called by `prepare_cluster_env` before envelope emission).
575    /// Sequential assignment by worker order: worker 0 owns
576    /// `[0..counts[0])`, worker 1 owns
577    /// `[counts[0]..counts[0]+counts[1])`, etc.
578    ///
579    /// Serialized into the wire format so the rank-side library reads
580    /// the post-probe assignment via `FullCluster::from_value`, and
581    /// deserializable so the canonical JSON round-trips. A `ranks:` key
582    /// in USER yaml is rejected loudly at load
583    /// (`loading::reject_user_ranks`): a user writing it expects it to
584    /// pin ranks, and it never did (probe is authoritative).
585    #[serde(default)]
586    pub ranks: Vec<usize>,
587    /// CUDA device indices paired by position with `ranks`, or `"all"`
588    /// shorthand for auto-detect at startup. See [`LocalDevices`] for
589    /// semantics.
590    pub local_devices: LocalDevices,
591    /// Network interface NCCL binds to (e.g. `virbr0`, `enp1s0`). Becomes
592    /// `NCCL_SOCKET_IFNAME` in the spawned process environment.
593    pub nccl_socket_ifname: String,
594    /// Project checkout path on this host. `fdl-cli` cd's here before
595    /// invoking the remote command. Heterogeneous mounts are fine
596    /// (e.g. `/opt/flodl` on the controller, `/srv/flodl` in a VM); training
597    /// data is the user's responsibility to mount identically across hosts
598    /// (NAS / SMB / virtiofs / S3-FUSE).
599    pub path: String,
600    /// SSH endpoint for `fdl-cli`'s launcher. `None` means the host
601    /// runs on the same machine as the launcher (fork/exec, no ssh).
602    /// When `Some`, all fields inside are optional and fall back to
603    /// system ssh defaults (or `~/.ssh/config` rules) when unset.
604    ///
605    /// In YAML, the sub-block accepts:
606    ///
607    /// ```yaml
608    /// ssh:
609    ///   target: node-b.lan          # ssh hostname/IP, default: host
610    ///   port: 2222                  # -p <port>
611    ///   user: ubuntu                # -l <user>
612    ///   identity_file: ~/.ssh/key   # -i <path>
613    ///   options:                    # -o <opt> (repeatable)
614    ///     - ProxyJump=bastion
615    ///     - StrictHostKeyChecking=no
616    /// ```
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub ssh: Option<SshConfig>,
619    /// Route this worker's training traffic through its fan-out SSH
620    /// session instead of a direct TCP connection to the controller:
621    /// the launcher adds a remote forward to the host's relay SSH
622    /// session and points the host at `127.0.0.1:<controller.port>`.
623    /// Requires a CPU ElChe mode (NCCL's peer-to-peer data plane cannot
624    /// ride a controller tunnel) and a remote host — validated loudly
625    /// at launch. When EVERY remote worker is tunneled, the controller
626    /// binds loopback only. Consumed by the library's launcher; fdl-cli
627    /// just carries it through the envelope.
628    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
629    pub tunnel: bool,
630    /// libtorch variant subpath under `<path>/libtorch/` on this host.
631    /// E.g. `precompiled/cu128` for a Blackwell host on PT 2.10 cu128,
632    /// `builds/sm61-sm120` for a Pascal host on a from-source build.
633    /// Convention: the convention path
634    /// `<host.path>/libtorch/<arch>` is what the rank exec reads at
635    /// runtime; the controller-side build mirrors it via
636    /// `<cluster.controller.path or controller-host.path>/libtorch/<arch>`.
637    /// Both paths point at the same physical libtorch via the shared
638    /// project-root mount.
639    ///
640    /// Optional; when unset, fdl-cli falls back to the host's
641    /// project-root `.active` file (single-host default behaviour
642    /// for non-cluster runs).
643    ///
644    /// `fdl probe`'s GPU compat check derives the supported sm
645    /// architectures by parsing the basename of this value (e.g.
646    /// `builds/sm61-sm120` → `6.1 12.0`) AND/OR reading the variant's
647    /// `.arch` metadata file at
648    /// `<path>/libtorch/<arch>/.arch`. The former wins when the
649    /// basename encodes archs; the latter covers `precompiled/cuXXX`
650    /// where the basename names the CUDA version, not GPU archs.
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub arch: Option<String>,
653    /// Dataset source root on this host: where its ranks READ training
654    /// data from, alongside model checkpoints and per-rank logs.
655    ///
656    /// Usually a shared filesystem (NAS / SMB / virtiofs / S3-FUSE /
657    /// SSHFS) reachable at the same logical path on every node, which
658    /// is the recommended shape and the one multi-host checkpointing
659    /// requires. A node-local directory is legal too, and then each
660    /// host holds its own copy. `fdl probe` verifies the path exists +
661    /// is readable on each host before training can fan out.
662    ///
663    /// **Declaring it changes a run.** The value travels to every rank
664    /// through the launcher envelope and supplies the training binary's
665    /// data directory (`LocalCluster::data_path` on the flodl side; an
666    /// explicit `--data-dir` still wins). When absent, nothing travels
667    /// and the binary keeps its own default: the convention default
668    /// [`DEFAULT_DATA_PATH`] governs the CHECKS only, because shipping
669    /// it would point every cluster that never mentioned data at a
670    /// directory most hosts do not have.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub data_path: Option<String>,
673    /// Integrated-GPU host-RAM share for THIS host: the fraction of
674    /// `MemTotal` its GPU aperture claims (the library's
675    /// `gpu_ram_share` knob; discrete GPUs ignore it). Host-hardware
676    /// truth like `data_path`: it travels to this host's ranks through
677    /// the launcher envelope and fills the training binary's config
678    /// when that left the knob unset (an explicit `with_gpu_ram_share`
679    /// in code still wins). Overrides the cluster-scope
680    /// [`ClusterConfig::gpu_ram_share`] default. Non-negative fraction
681    /// of `MemTotal` (above 1.0 is legal where the platform
682    /// under-states the aperture).
683    #[serde(default, skip_serializing_if = "Option::is_none")]
684    pub gpu_ram_share: Option<f64>,
685    /// Names the docker compose service that provides this host's
686    /// runtime environment (e.g. `cuda`, `dev`). It does NOT wrap the
687    /// training exec: cluster fan-out runs each rank's binary directly
688    /// on the host over SSH (the pre-flight build is what runs in a
689    /// container, in the *controller's* [`ClusterController`] `docker`).
690    /// This field is a probe-time signal only: when set, `fdl probe`
691    /// skips host-level NCCL discovery — NCCL ships inside the image,
692    /// not on the host — and reports "provided via Docker image
693    /// `<svc>`" instead of erroring on a missing `libnccl.so`. The
694    /// host's libtorch (resolved via the `<path>/libtorch/<arch>`
695    /// convention) is still validated because it's the bind-mount
696    /// target, not container state. Per-host (not global) because mixed
697    /// deployments are common: controller in Docker, worker bare-metal
698    /// (or vice-versa). Library ignores this field; consumed only by
699    /// fdl-cli's probe / deploy paths.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub docker: Option<String>,
702    /// Host-scoped env vars exported into every rank child spawned on
703    /// this host. Mapping `NAME: VALUE` (string→string). Useful for
704    /// host-specific tuning that doesn't belong at cluster scope
705    /// (e.g. one host needs a different `NCCL_SOCKET_IFNAME` override,
706    /// or a custom `LD_LIBRARY_PATH` due to a non-standard CUDA
707    /// install). Overrides matching keys from
708    /// [`ClusterConfig::env`].
709    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
710    pub env: std::collections::BTreeMap<String, String>,
711}
712
713/// Convention default for [`ClusterWorker::data_path`] /
714/// [`ClusterController::data_path`] when the entry does not declare
715/// one. Maps to the cross-node mount that holds training data +
716/// checkpoints + per-rank logs.
717pub const DEFAULT_DATA_PATH: &str = "/flodl/data";
718
719impl ClusterWorker {
720    /// Effective shared-data path: `data_path` if set, else
721    /// [`DEFAULT_DATA_PATH`].
722    pub fn effective_data_path(&self) -> &str {
723        self.data_path.as_deref().unwrap_or(DEFAULT_DATA_PATH)
724    }
725}
726
727impl ClusterConfig {
728    /// Total ranks across the cluster.
729    pub fn world_size(&self) -> usize {
730        self.workers.iter().map(|w| w.ranks.len()).sum()
731    }
732
733    /// Whether the cluster spans more than one physical worker.
734    /// Single-worker clusters don't require `NCCL_SOCKET_IFNAME`.
735    pub fn spans_multiple_hosts(&self) -> bool {
736        self.workers.len() > 1
737    }
738
739    /// Pre-flight validation. Mirrors the library check so failures
740    /// surface before SSH dispatch instead of from a stack trace on a
741    /// remote host:
742    ///
743    /// - `controller.host` non-empty, `controller.path` non-empty
744    /// - `workers` non-empty
745    /// - per worker: `host` non-empty, `nccl_socket_ifname` non-empty
746    ///   (when cluster spans multiple workers), `path` non-empty
747    ///
748    /// Ranks are NOT user input — they're computed by
749    /// [`Self::populate_ranks`] from probed device counts. When this
750    /// runs pre-probe (ranks empty), the rank-shape check is skipped;
751    /// when called post-probe (ranks populated), the
752    /// `0..world_size` + length-match-vs-local_devices invariant is
753    /// enforced.
754    pub fn validate(&self) -> Result<(), String> {
755        if self.controller.host.trim().is_empty() {
756            return Err("cluster.controller.host must be non-empty".into());
757        }
758        if self.controller.path.trim().is_empty() {
759            return Err("cluster.controller.path must be non-empty".into());
760        }
761        let discovery = self
762            .controller
763            .join
764            .as_ref()
765            .is_some_and(|j| j.discovery == Some(true));
766        if self.workers.is_empty() && !discovery {
767            return Err("cluster.workers must be non-empty (a roster-free \
768                        window needs `controller.join.discovery: true`)"
769                .into());
770        }
771        if let Some(start) = self
772            .controller
773            .join
774            .as_ref()
775            .and_then(|j| j.start.as_deref())
776            && !matches!(start, "auto" | "manual" | "hybrid")
777        {
778            return Err(format!(
779                "cluster.controller.join.start must be one of \
780                     auto | manual | hybrid, got {start:?}"
781            ));
782        }
783        // Reserved-env-key check: a user env map must not carry a key the
784        // launcher owns per-rank. The launcher applies user env after its
785        // own built-ins (shell last-wins), so an override would silently
786        // break device mapping / rank identity / the HMAC envelope. The
787        // reserved predicate lives in the library (single source of truth
788        // for the launcher-owned names).
789        for k in self.env.keys() {
790            if crate::cluster::is_reserved_cluster_env_key(k) {
791                return Err(format!(
792                    "cluster.env: key {k:?} is reserved (launcher-owned) and \
793                     cannot be set via env — it would override the launcher's \
794                     per-rank value"
795                ));
796            }
797        }
798        for (i, w) in self.workers.iter().enumerate() {
799            for k in w.env.keys() {
800                if crate::cluster::is_reserved_cluster_env_key(k) {
801                    return Err(format!(
802                        "cluster.workers[{i}] ({:?}): env key {k:?} is reserved \
803                         (launcher-owned) and cannot be set via env — it would \
804                         override the launcher's per-rank value",
805                        w.host,
806                    ));
807                }
808            }
809        }
810        let multi_host = self.spans_multiple_hosts();
811        for (i, w) in self.workers.iter().enumerate() {
812            if w.host.trim().is_empty() {
813                return Err(format!("cluster.workers[{i}].host must be non-empty"));
814            }
815            if multi_host && w.nccl_socket_ifname.trim().is_empty() {
816                return Err(format!(
817                    "cluster.workers[{i}] ({:?}): nccl_socket_ifname must be \
818                     non-empty when the cluster spans multiple workers",
819                    w.host
820                ));
821            }
822            if w.path.trim().is_empty() {
823                return Err(format!(
824                    "cluster.workers[{i}] ({:?}): path (project checkout dir) \
825                     must be non-empty",
826                    w.host
827                ));
828            }
829        }
830        // Post-probe shape checks: only when ranks have been populated.
831        // Pre-probe (all empty) skips this branch.
832        if self.workers.iter().all(|w| !w.ranks.is_empty()) {
833            for (i, w) in self.workers.iter().enumerate() {
834                if let Some(devs) = w.local_devices.as_explicit()
835                    && w.ranks.len() != devs.len()
836                {
837                    return Err(format!(
838                        "cluster.workers[{i}] ({:?}): ranks ({}) and local_devices ({}) length mismatch",
839                        w.host,
840                        w.ranks.len(),
841                        devs.len()
842                    ));
843                }
844            }
845            let mut all: Vec<usize> = self
846                .workers
847                .iter()
848                .flat_map(|w| w.ranks.iter().copied())
849                .collect();
850            let ws = all.len();
851            all.sort_unstable();
852            let expected: Vec<usize> = (0..ws).collect();
853            if all != expected {
854                return Err(format!(
855                    "cluster: ranks across workers must be exactly 0..{ws} with no \
856                     duplicates or gaps, got sorted-unique sequence {all:?}"
857                ));
858            }
859        }
860        Ok(())
861    }
862
863    /// Populate `workers[i].ranks` from probed device counts.
864    /// Sequential assignment by worker order: worker 0 owns
865    /// `[0..counts[0])`, worker 1 owns
866    /// `[counts[0]..counts[0]+counts[1])`, etc.
867    ///
868    /// Errors when `device_counts.len() != workers.len()` or any
869    /// count is 0. Workers' existing `ranks` are unconditionally
870    /// overwritten — they're not user input, the probe is
871    /// authoritative.
872    ///
873    /// Caller orchestration (see `prepare_cluster_env`):
874    /// 1. parse YAML → ClusterConfig (ranks empty by serde default)
875    /// 2. probe device counts per worker
876    /// 3. call `populate_ranks` to fill in
877    /// 4. validate (now ranks are non-empty → shape checks run)
878    /// 5. serialize and ship via FLODL_INTERNAL_FULL_CLUSTER_JSON
879    pub fn populate_ranks(&mut self, device_counts: &[usize]) -> Result<(), String> {
880        if device_counts.len() != self.workers.len() {
881            return Err(format!(
882                "populate_ranks: device_counts len {} != workers len {}",
883                device_counts.len(),
884                self.workers.len(),
885            ));
886        }
887        let mut next_rank = 0usize;
888        for (i, w) in self.workers.iter_mut().enumerate() {
889            let count = device_counts[i];
890            if count == 0 {
891                return Err(format!(
892                    "populate_ranks: worker[{i}] ({:?}) reported 0 devices",
893                    w.host,
894                ));
895            }
896            w.ranks = (next_rank..next_rank + count).collect();
897            next_rank += count;
898        }
899        Ok(())
900    }
901
902    /// Canonical JSON of the full topology. Used for debug-dumping the
903    /// controller-side view; per-worker envelopes go through
904    /// [`Self::local_envelope_for`] instead.
905    pub fn canonical_json(&self) -> Result<String, String> {
906        serde_json::to_string_pretty(self)
907            .map_err(|e| format!("cluster: JSON serialization failed: {e}"))
908    }
909
910    /// Build the slim per-worker envelope the library reads via
911    /// `flodl::distributed::LocalCluster::from_env`.
912    ///
913    /// The envelope strips launcher-only fields (`ssh*`), embeds derived
914    /// world metadata (`world_size`, `num_workers`), and carries only
915    /// the requested worker's slice. The launcher hex-encodes the
916    /// resulting JSON into `FLODL_INTERNAL_CLUSTER_JSON` per ssh invocation, so
917    /// each remote process sees only itself + the controller
918    /// coordinates.
919    pub fn local_envelope_for(&self, worker: &ClusterWorker) -> Value {
920        let mut worker_obj = serde_json::Map::new();
921        worker_obj.insert("host".into(), Value::String(worker.host.clone()));
922        worker_obj.insert(
923            "ranks".into(),
924            Value::Array(worker.ranks.iter().map(|r| Value::from(*r)).collect()),
925        );
926        worker_obj.insert(
927            "local_devices".into(),
928            match &worker.local_devices {
929                LocalDevices::All => Value::String("all".into()),
930                LocalDevices::Explicit(v) => {
931                    Value::Array(v.iter().map(|d| Value::from(*d)).collect())
932                }
933            },
934        );
935        worker_obj.insert(
936            "nccl_socket_ifname".into(),
937            Value::String(worker.nccl_socket_ifname.clone()),
938        );
939        worker_obj.insert("path".into(), Value::String(worker.path.clone()));
940        if let Some(a) = &worker.arch {
941            worker_obj.insert("arch".into(), Value::String(a.clone()));
942        }
943        // SSH fields (port/user/identity_file/options) are launcher-only.
944        // The slim per-rank envelope read by `LocalCluster::from_env`
945        // doesn't need them — the rank-side library has nothing to do
946        // with SSH. They're already on the FULL envelope via serde on
947        // `ClusterConfig` so the launcher picks them up there.
948        //
949        // Dataset source root: DECLARED values only, never
950        // `effective_data_path()`. The convention default names a path
951        // that most hosts do not actually have, and the rank consumes
952        // this one (it reaches `--data-dir` through
953        // `LocalCluster::data_path`), so shipping the default would
954        // point every run that never mentioned data at a directory that
955        // is not there. Absent = the training binary keeps its own
956        // default. Same split `check_remote_data_path` already makes:
957        // a declared path that is missing is an error, a convention
958        // default that is missing is a warning.
959        if let Some(dp) = &worker.data_path {
960            worker_obj.insert("data_path".into(), Value::String(dp.clone()));
961        }
962
963        let mut controller_obj = serde_json::Map::new();
964        controller_obj.insert("host".into(), Value::String(self.controller.host.clone()));
965        controller_obj.insert("port".into(), Value::from(self.controller.port));
966
967        let mut envelope = serde_json::Map::new();
968        envelope.insert("controller".into(), Value::Object(controller_obj));
969        envelope.insert("world_size".into(), Value::from(self.world_size()));
970        envelope.insert("num_workers".into(), Value::from(self.workers.len()));
971        envelope.insert("worker".into(), Value::Object(worker_obj));
972        Value::Object(envelope)
973    }
974
975    /// Look up the SSH target for a worker, defaulting to `host` if
976    /// `ssh:` is not set. Used by the launcher; library callers don't
977    /// need this.
978    pub fn ssh_target<'a>(&'a self, worker: &'a ClusterWorker) -> &'a str {
979        worker
980            .ssh
981            .as_ref()
982            .and_then(|s| s.target.as_deref())
983            .unwrap_or(&worker.host)
984    }
985}