Skip to main content

flodl_cli/
prepare.rs

1//! Training preparation — get this box ready before it dials in.
2//!
3//! `fdl join` runs this once per attempt, strictly BEFORE the tunnel and
4//! the dial: admission starts a window deadline, so anything acquired
5//! after it burns the deadline. Every step is idempotent, which is what
6//! makes `--persist` a provisioning loop for free — a box picks up a
7//! changed source on its next re-dial, with no reprovisioning.
8//!
9//! Five steps, in an order that is itself load-bearing. First the cheap
10//! ones: gate on the GPU stack, put the dataset source root where the
11//! ranks will look for it, prove the node-local directories the data
12//! plane writes are writable. Then what a box may not have yet — the
13//! training source, a libtorch variant, and the binary built from both —
14//! because those take minutes, and discovering an unwritable stage
15//! directory after a cold build has wasted the build.
16//!
17//! Failures split two ways, and the split is the point. `--persist`
18//! re-dials forever with backoff, which is right for a controller that
19//! is not up yet and wrong for a box with no GPU: without the
20//! distinction a misprovisioned instance hot-loops instead of stopping.
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use crate::config::{DEFAULT_DATA_PATH, SshConfig};
26use crate::context::Context;
27use crate::source::{Built, Manifest};
28use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
29use crate::style;
30
31/// Why preparation stopped, and whether trying again could help.
32#[derive(Debug, PartialEq, Eq)]
33pub enum Fail {
34    /// Retrying cannot help: no usable GPU, a spec that does not parse,
35    /// a directory that cannot be created. Report and stop, even under
36    /// `--persist`.
37    Permanent(String),
38    /// The next attempt may well work: the far side of a mount is down,
39    /// the controller has not opened its window yet. Back off, re-dial.
40    Transient(String),
41}
42
43impl Fail {
44    /// Message without the class tag.
45    pub fn message(&self) -> &str {
46        match self {
47            Fail::Permanent(m) | Fail::Transient(m) => m,
48        }
49    }
50
51    /// True for [`Fail::Permanent`] — the caller must not re-dial.
52    pub fn is_permanent(&self) -> bool {
53        matches!(self, Fail::Permanent(_))
54    }
55}
56
57/// What preparation settled, for the join that follows.
58#[derive(Debug, Default, PartialEq, Eq)]
59pub struct Prepared {
60    /// Local dataset source root to hand this host's ranks. `None` when
61    /// the box declares no data path — the training binary then keeps
62    /// its own default, which is what a run that never mentions data
63    /// expects.
64    pub data_path: Option<PathBuf>,
65    /// The libtorch this box will train against: `(variant directory,
66    /// variant label)`. Acquired when a spec asked for one, otherwise
67    /// whatever was already active here. One field rather than two
68    /// because the answer has one authority: the build links against it
69    /// and the ranks load from it.
70    pub libtorch: Option<(PathBuf, String)>,
71    /// The training binary this box built, and the directory it expects
72    /// as cwd. `None` when the operator named a binary instead of a
73    /// source.
74    pub bin: Option<Built>,
75    /// The run's arguments, when a controller published them. They
76    /// replace whatever this box carried: args must match the run,
77    /// because rank children re-enter the binary with them.
78    pub args: Option<Vec<String>>,
79    /// The published run's identity nonce, when the manifest carries
80    /// one. Rides the join hello so the window can refuse a cohort
81    /// straddling a publish boundary; `None` gates nothing.
82    pub run_id: Option<String>,
83}
84
85/// Everything this box has to settle before it dials.
86#[derive(Debug, Default)]
87pub struct PrepareSpec<'a> {
88    pub data: DataSpec<'a>,
89    /// libtorch variant to acquire: `auto`, `cpu`, `cu126`, `cu128`,
90    /// `rocm7.0`, `rocm7.1`. `None` leaves this box on whatever it
91    /// already has.
92    pub libtorch: Option<&'a str>,
93    /// This box's already-active libtorch, when fdl found one. Used when
94    /// no variant is acquired, and it is what the build links against.
95    pub active_libtorch: Option<&'a (PathBuf, String)>,
96    /// Training source to fetch and build. `None` when the operator
97    /// named an existing binary.
98    pub source: Option<SourceSpec<'a>>,
99    /// Device ids this box will offer (`--devices`); `None` = all
100    /// visible. The arch-coverage gate scopes to these: a half-covered
101    /// box explicitly offering only its covered card is a working box.
102    pub devices: Option<&'a [u8]>,
103}
104
105/// The source half of the join recipe.
106#[derive(Debug, Default)]
107pub struct SourceSpec<'a> {
108    /// Transport plus location (see [`crate::source::parse`]).
109    pub from: &'a str,
110    /// Project directory inside the fetched tree. Governs the build AND
111    /// the run, so it answers "where is the project in this tree" once.
112    pub cwd: Option<&'a str>,
113    /// Build recipe, a shell line. `None` uses cargo's release build.
114    pub build: Option<&'a str>,
115    /// Built artifact, relative to `cwd`. `None` when this box leaves it
116    /// to the controller's run manifest.
117    pub bin: Option<&'a str>,
118    /// The join block's ssh credentials, for a transport that needs
119    /// them. Same reuse (and same caveat) as [`DataSpec::ssh`].
120    pub ssh: Option<&'a SshConfig>,
121}
122
123/// The data half of the join recipe, as resolved from flags over the
124/// `join:` block.
125#[derive(Debug, Default)]
126pub struct DataSpec<'a> {
127    /// Local source root (the mountpoint, when `source` is set).
128    pub path: Option<&'a str>,
129    /// `<scheme>://<target>` transport that establishes `path`.
130    pub source: Option<&'a str>,
131    /// The join block's tunnel credentials. Reused for the data mount:
132    /// in the shape this exists for, the data host IS the controller box
133    /// the tunnel already authenticates against, so a second key field
134    /// would be surface that only ever repeats the first one.
135    ///
136    /// One consequence belongs in the operator's hands rather than in
137    /// their debugging: a forced `command=` on that key covers subsystem
138    /// requests, so a join key guardrailed with `/usr/sbin/nologin`
139    /// turns sftp away and the mount never comes up while the tunnel
140    /// keeps working (`ssh -N` requests no command at all). Either the
141    /// key permits sftp (`internal-sftp -R`), or the source root is
142    /// mounted during provisioning and `path` is declared bare — which
143    /// is also the answer for a box whose mount needs different
144    /// credentials entirely. Both spellings are in the guardrail recipe
145    /// in docs/ddp/02-cluster-guide.md.
146    pub ssh: Option<&'a SshConfig>,
147}
148
149/// Node-local dataset cache, one level up from what it holds. Mirrors
150/// flodl's `data::host_cache::data_cache_dir()` — the `$HOME/.flodl`
151/// convention is fdl's, and flodl-cli is zero-dep on flodl by design, so
152/// the two spell it separately. Only the parent is checked here: the
153/// per-dataset subdirectory below it is the application's business.
154const CACHE_SUBPATH: &str = ".flodl/data";
155
156/// Free space below which a local directory gets a warning: smaller than
157/// any real corpus, so it is almost certainly not the volume the
158/// operator meant to train from.
159const LOW_SPACE_KIB: u64 = 1 << 20;
160
161/// Where a fetched source tree lands, under the global root. Sibling of
162/// the `libtorch/` and `data/` the same root already carries, per the
163/// convention that anything fdl manages locally on one box lives there
164/// while only paths a config names across hosts are absolute.
165const SOURCE_SUBDIR: &str = "source";
166
167/// Prepare this box. `notes` collects everything worth telling the
168/// operator that is not a failure (a reused mount, a tmpfs stage, a
169/// nearly-full volume); the caller prints them.
170///
171/// Cheap before expensive: the gate, the mount and the write proofs all
172/// finish in about the time it takes to say so, while a source fetch and
173/// a cold build take minutes. The source comes before libtorch for the
174/// same reason at a smaller scale — a tree is megabytes and a libtorch
175/// variant is gigabytes, so a broken spec should fail before the
176/// download rather than after it.
177pub fn prepare(spec: &PrepareSpec, notes: &mut Vec<String>) -> Result<Prepared, Fail> {
178    check_gpu_stack()?;
179    let data_path = resolve_data_root(&spec.data, notes)?;
180    check_local_dirs(notes)?;
181
182    let fetched = match &spec.source {
183        Some(source) => Some(fetch_source(source, notes)?),
184        None => None,
185    };
186    let libtorch = match spec.libtorch {
187        Some(token) => Some(acquire_libtorch(token, notes)?),
188        None => spec.active_libtorch.cloned(),
189    };
190    if let Some(lt) = &libtorch {
191        check_arch_coverage(lt, spec.devices)?;
192    }
193    let (bin, args, run_id) = match (&spec.source, &fetched) {
194        (Some(source), Some((tree, manifest))) => {
195            let recipe = merge_manifest(source, manifest.as_ref())?;
196            let built = build_source(&recipe, tree, libtorch.as_ref(), notes)?;
197            (
198                Some(built),
199                manifest.as_ref().map(|m| m.args.clone()),
200                manifest.as_ref().and_then(|m| m.run.clone()),
201            )
202        }
203        _ => (None, None, None),
204    };
205    Ok(Prepared {
206        data_path,
207        libtorch,
208        bin,
209        args,
210        run_id,
211    })
212}
213
214/// What to build, once the controller has had its say.
215///
216/// The manifest wins over the box's own config, and that is the point of
217/// it: everything here belongs to the RUN, and a cohort where one box
218/// disagrees about the binary or its arguments is not a cohort. The local
219/// values stay as the answer when nobody has published — a rig where the
220/// operator drives `fdl join` by hand needs no publish at all.
221fn merge_manifest<'a>(
222    local: &'a SourceSpec<'a>,
223    manifest: Option<&'a Manifest>,
224) -> Result<Recipe<'a>, Fail> {
225    let Some(m) = manifest else {
226        let Some(bin) = local.bin else {
227            // Nothing published, and nothing declared here either: the
228            // box cannot know what to run. Transient, because the far
229            // side publishing is exactly what fixes it — including the
230            // window where a publish has cleared the manifest and not
231            // yet written the new one.
232            return Err(Fail::Transient(
233                "the fetched source carries no run manifest and this box \
234                 declares no artifact — publish a run on the controller \
235                 (`fdl publish`), or name it locally with `--source-bin`"
236                    .to_string(),
237            ));
238        };
239        return Ok(Recipe {
240            cwd: local.cwd,
241            build: local.build,
242            bin,
243        });
244    };
245    Ok(Recipe {
246        cwd: m.cwd.as_deref().or(local.cwd),
247        build: m.build.as_deref().or(local.build),
248        bin: &m.bin,
249    })
250}
251
252/// The resolved build recipe: the manifest's answers where it has them,
253/// the box's own where it does not.
254#[derive(Debug)]
255struct Recipe<'a> {
256    cwd: Option<&'a str>,
257    build: Option<&'a str>,
258    bin: &'a str,
259}
260
261// ---------------------------------------------------------------------------
262// libtorch
263// ---------------------------------------------------------------------------
264
265/// Acquire a libtorch variant and return `(variant directory, label)`.
266///
267/// Into the GLOBAL root, never the project one: a walk-in is consuming
268/// artifacts, and the project root it happens to stand in is frequently a
269/// read-only shared mount (see [`Context::global`]). Idempotent — the
270/// downloader recognises a variant already installed at the pinned
271/// version and returns without touching the network.
272///
273/// `auto` is the fleet-friendly value: it routes on the devices this box
274/// actually has, so one golden image serves NVIDIA and AMD instances.
275fn acquire_libtorch(token: &str, notes: &mut Vec<String>) -> Result<(PathBuf, String), Fail> {
276    let variant = parse_libtorch_token(token)?;
277    let ctx = Context::global();
278    let id = crate::libtorch::download::run_with_context(
279        crate::libtorch::download::DownloadOpts {
280            variant,
281            custom_path: None,
282            // Write `.active` under the global root so anything else fdl
283            // does on this box afterwards agrees with what trains here.
284            activate: true,
285            dry_run: false,
286            force_linux: false,
287        },
288        &ctx,
289    )
290    // A download that fails is the network, or a mirror having a bad
291    // day: worth another dial rather than stopping the box.
292    .map_err(Fail::Transient)?;
293
294    let dir = ctx.root.join("libtorch").join(&id);
295    if !dir.join("lib").is_dir() {
296        return Err(Fail::Permanent(format!(
297            "libtorch `{id}` is not usable at {} (no lib/) — remove it and \
298             let fdl fetch it again",
299            dir.display(),
300        )));
301    }
302    notes.push(format!("libtorch: {id} at {}", dir.display()));
303    Ok((dir, id))
304}
305
306/// Map a `libtorch:` value onto a downloadable variant. The accepted
307/// values are `fdl libtorch download`'s own flags spelled as one token,
308/// so the two surfaces cannot drift into naming different things.
309fn parse_libtorch_token(token: &str) -> Result<crate::libtorch::download::Variant, Fail> {
310    use crate::libtorch::download::Variant;
311    match token.trim() {
312        "auto" => Ok(Variant::Auto),
313        "cpu" => Ok(Variant::Cpu),
314        "cu126" | "12.6" => Ok(Variant::Cuda126),
315        "cu128" | "12.8" => Ok(Variant::Cuda128),
316        "rocm7.0" | "rocm70" | "7.0" => Ok(Variant::Rocm70),
317        "rocm7.1" | "rocm71" | "7.1" => Ok(Variant::Rocm71),
318        other => Err(Fail::Permanent(format!(
319            "unknown libtorch variant `{other}` — fdl ships `auto`, `cpu`, \
320             `cu126`, `cu128`, `rocm7.0` and `rocm7.1`. `auto` picks from the \
321             devices this box has, which is what lets one image serve both \
322             vendors"
323        ))),
324    }
325}
326
327// ---------------------------------------------------------------------------
328// The training source
329// ---------------------------------------------------------------------------
330
331/// Materialise the source tree on local disk and hand back its root plus
332/// whatever run manifest came with it.
333fn fetch_source(
334    spec: &SourceSpec,
335    notes: &mut Vec<String>,
336) -> Result<(PathBuf, Option<Manifest>), Fail> {
337    let source = crate::source::parse(spec.from)?;
338    let dest = Context::global().root.join(SOURCE_SUBDIR);
339    crate::source::materialize(&source, &dest, spec.ssh, notes)?;
340    let manifest = Manifest::read(&dest)?;
341    if let Some(m) = &manifest {
342        notes.push(format!(
343            "run manifest: {}bin {}{}{}{}",
344            m.run
345                .as_deref()
346                .map(|r| format!("run {}… ", &r[..r.len().min(8)]))
347                .unwrap_or_default(),
348            m.bin,
349            m.cwd
350                .as_deref()
351                .map(|c| format!(" in {c}"))
352                .unwrap_or_default(),
353            m.published_epoch
354                .and_then(age_hint)
355                .map(|age| format!(", published {age}"))
356                .unwrap_or_default(),
357            if m.built {
358                ""
359            } else {
360                " — NOT built by the controller"
361            },
362        ));
363        if let (Some(theirs), Some(ours)) = (&m.rustc, local_rustc())
364            && theirs != &ours
365        {
366            notes.push(format!(
367                "the controller built this with {theirs}, this box has \
368                     {ours} — advisory only, every box compiles its own \
369                     binary and a toolchain too old fails loudly at compile \
370                     time",
371            ));
372        }
373    }
374    Ok((dest, manifest))
375}
376
377/// How long ago a publish happened, in the roughest terms that are still
378/// useful. `None` when the clock disagrees with the manifest (a box whose
379/// time has not synced yet says nothing rather than something wrong).
380fn age_hint(then: u64) -> Option<String> {
381    let now = std::time::SystemTime::now()
382        .duration_since(std::time::UNIX_EPOCH)
383        .ok()?
384        .as_secs();
385    let secs = now.checked_sub(then)?;
386    Some(match secs {
387        0..=90 => "just now".to_string(),
388        s if s < 5400 => format!("{}m ago", s / 60),
389        s if s < 172_800 => format!("{}h ago", s / 3600),
390        s => format!("{}d ago", s / 86_400),
391    })
392}
393
394/// `rustc -V` here, for the manifest's advisory comparison.
395fn local_rustc() -> Option<String> {
396    let out = Command::new("rustc").arg("-V").output().ok()?;
397    out.status
398        .success()
399        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
400        .filter(|v| !v.is_empty())
401}
402
403/// Build the fetched tree.
404fn build_source(
405    recipe: &Recipe,
406    tree: &Path,
407    libtorch: Option<&(PathBuf, String)>,
408    notes: &mut Vec<String>,
409) -> Result<Built, Fail> {
410    if libtorch.is_none() {
411        notes.push(
412            "no libtorch is active on this box and none was requested, so \
413             the build gets no LIBTORCH_PATH — set `libtorch:` (`auto` \
414             picks for this box) unless the recipe supplies its own"
415                .to_string(),
416        );
417    }
418    let env = crate::source::build_env(libtorch);
419    crate::source::build(tree, recipe.cwd, recipe.build, recipe.bin, &env, notes).map_err(|e| {
420        if e.is_permanent() {
421            return e;
422        }
423        // A failed build while the vendor's toolkit headers are missing
424        // is a PROVISIONING fault wearing a compile error: waiting
425        // cannot install a package, and ROCm needs seven -dev packages
426        // with no metapackage, so this is the predicted first-contact
427        // failure on a golden AMD image. Classed at failure time rather
428        // than as a pre-flight, deliberately — fdl passes no feature
429        // flag of its own and cannot know whether the recipe needed the
430        // toolkit (a cpu-feature crate builds fine without it), but a
431        // build that FAILED while the toolkit is demonstrably absent is
432        // the case re-dialing provably cannot fix.
433        if let Some((_, variant)) = libtorch
434            && let flodl_hw::VariantClass::Vendor(vendor) =
435                flodl_hw::classify_variant_label(variant)
436            && let Some(gap) = crate::util::requirements::toolkit_gap(vendor)
437        {
438            return Fail::Permanent(format!(
439                "{} — and this box is missing the {vendor} toolkit \
440                         headers under {} ({}), which a `--features {}` \
441                         compile needs. Re-dialing cannot install a package: \
442                         {}",
443                e.message(),
444                gap.root.display(),
445                gap.headers.join(", "),
446                vendor.cargo_feature(),
447                gap.install,
448            ));
449        }
450        // Still transient, with the worker's next step spelled out: this
451        // box cannot fix a compile error, and it must not stop over one.
452        Fail::Transient(format!(
453            "{} — fix it at the source; this box picks the fix up on its \
454             next dial",
455            e.message(),
456        ))
457    })
458}
459
460// ---------------------------------------------------------------------------
461// The GPU gate
462// ---------------------------------------------------------------------------
463
464/// Refuse to dial from a box that has no rank to offer.
465///
466/// The agent already rejects an empty device list, but only after
467/// admission — by then this host has been counted into a quorum and its
468/// failure takes the cohort's formation with it. Same verdict, before the
469/// window instead of inside it.
470///
471/// The bar is deliberately "any usable device", not "nothing to report".
472/// `fdl probe` answers a broader question — is everything on this box
473/// configured correctly — and flags an unusable card even when other
474/// cards work; a mixed box (an AMD iGPU with no ROCm runtime beside two
475/// working NVIDIA cards) is a normal, trainable box, and treating
476/// probe's findings as a verdict here would refuse it. So the findings
477/// stay what they are: the *explanation* when there is genuinely nothing,
478/// which is what `require_devices` quotes.
479///
480/// Masks are applied (`CUDA_VISIBLE_DEVICES=` means zero devices for the
481/// rank, whatever is installed) but the vendor filter is not: `fdl` is
482/// built for no GPU backend, and the training binary is built for exactly
483/// one. So this gate is a superset — it blocks a box with nothing at all,
484/// and leaves "these devices are the wrong vendor for me" to the process
485/// that knows its own backend.
486fn check_gpu_stack() -> Result<(), Fail> {
487    flodl_hw::survey_visible()
488        .require_devices()
489        .map(|_| ())
490        .map_err(|why| {
491            Fail::Permanent(format!(
492                "{why} This box has no rank to offer; `fdl probe` has the \
493                 full picture. (A driver still coming up at boot belongs \
494                 before `fdl join`, not inside its re-dial loop.)"
495            ))
496        })
497}
498
499/// Refuse a libtorch that ships no kernel for a card this box offers.
500///
501/// [`check_gpu_stack`] proves devices EXIST; this proves the resolved
502/// variant can address them. Without it a Pascal-class box holding a
503/// cu128-only build passes every gate, is admitted into a quorum,
504/// builds successfully, and dies at its FIRST GPU op with `no kernel
505/// image is available` — after the window was spent, taking the
506/// cohort's formation with it. This is the arch-coherence check the
507/// membership design promised, landed where the information lives: the
508/// variant's `.arch` metadata and the device list are both local facts.
509///
510/// Scope, deliberately narrow: only devices of the variant's OWN vendor
511/// are consulted (an unusable other-vendor iGPU beside working cards is
512/// a trainable box — the same lesson as the GPU gate), only devices
513/// this box offers (`--devices` scopes a half-covered box onto its
514/// covered card), and a variant with no `.arch` metadata gates nothing
515/// here — `fdl probe` flags missing metadata as its own issue, and
516/// refusing to dial over it would stop working setups.
517fn check_arch_coverage(libtorch: &(PathBuf, String), offered: Option<&[u8]>) -> Result<(), Fail> {
518    let (dir, label) = libtorch;
519    let flodl_hw::VariantClass::Vendor(vendor) = flodl_hw::classify_variant_label(label) else {
520        return Ok(());
521    };
522    let info = crate::libtorch::detect::libtorch_info_from_dir(label.clone(), dir);
523    let Some(archs) = info.archs.clone() else {
524        return Ok(());
525    };
526    let devices: Vec<_> = flodl_hw::survey_visible()
527        .devices
528        .into_iter()
529        .filter(|d| d.vendor == vendor)
530        .filter(|d| offered.is_none_or(|ids| ids.contains(&d.index)))
531        .collect();
532    if devices.is_empty() {
533        // The variant's vendor has nothing here — whether that is fine
534        // is the training binary's question, not this gate's.
535        return Ok(());
536    }
537    let mut details = Vec::new();
538    let coverage = crate::libtorch::detect::arch_coverage(&info, &devices, &mut details);
539    if coverage.iter().all(|(_, ok)| *ok) {
540        return Ok(());
541    }
542    Err(Fail::Permanent(format!(
543        "libtorch `{label}` (archs `{archs}`) ships no kernel for part of \
544         what this box offers: {} The first GPU op would die with `no \
545         kernel image is available` — after admission counted this host \
546         into a quorum. `libtorch: auto` picks a covering variant when \
547         one exists; `--devices` can scope the offer to covered cards",
548        details.join(" "),
549    )))
550}
551
552// ---------------------------------------------------------------------------
553// The dataset source root
554// ---------------------------------------------------------------------------
555
556/// Put the source root where the ranks will look, and return that path.
557///
558/// Three shapes, and the empty one is the common case:
559/// - neither field: ship nothing, check nothing.
560/// - `path` alone: a root provisioning already placed. Verified, shipped.
561/// - `source` (with `path` as the mountpoint, default
562///   [`DEFAULT_DATA_PATH`]): established here when it is not already up.
563fn resolve_data_root(spec: &DataSpec, notes: &mut Vec<String>) -> Result<Option<PathBuf>, Fail> {
564    let Some(source) = spec.source else {
565        let Some(path) = spec.path else {
566            return Ok(None);
567        };
568        let path = absolute(path)?;
569        verify_source_root(&path)?;
570        return Ok(Some(path));
571    };
572
573    let mountpoint = absolute(spec.path.unwrap_or(DEFAULT_DATA_PATH))?;
574    let target = parse_source(source)?;
575    ensure_mountpoint(&mountpoint)?;
576
577    match crate::probe::mounted_at(&mountpoint) {
578        Some((mounted_source, fs_type)) => {
579            if mounted_source != target.remote {
580                notes.push(format!(
581                    "{} already carries a mount from `{mounted_source}` \
582                     ({fs_type}), not the configured `{}` — leaving it \
583                     alone; the ranks will read whatever is mounted \
584                     there. Unmount it (`fusermount -u {}`) to let fdl \
585                     mount the configured source.",
586                    mountpoint.display(),
587                    target.remote,
588                    mountpoint.display(),
589                ));
590            } else {
591                notes.push(format!(
592                    "source root {} already mounted from `{mounted_source}` \
593                     ({fs_type})",
594                    mountpoint.display(),
595                ));
596            }
597        }
598        None => {
599            mount_sshfs(&target, &mountpoint, spec.ssh)?;
600            notes.push(format!(
601                "mounted `{}` read-only at {}",
602                target.remote,
603                mountpoint.display(),
604            ));
605        }
606    }
607    verify_source_root(&mountpoint)?;
608    Ok(Some(mountpoint))
609}
610
611/// Absolutize a declared path without resolving symlinks: the value is
612/// shipped to this host's ranks, and a relative one would resolve against
613/// each reader's working directory. Lexical on purpose —
614/// `canonicalize` would silently replace what the operator declared with
615/// whatever its symlinks point at.
616fn absolute(path: &str) -> Result<PathBuf, Fail> {
617    std::path::absolute(path)
618        .map_err(|e| Fail::Permanent(format!("cannot resolve data path `{path}`: {e}")))
619}
620
621/// A readable directory is all a source root has to be — a rank reads it
622/// and never writes it, so an unwritable one is the NORMAL case, not a
623/// fault (see `flodl::data::host_cache`).
624fn verify_source_root(path: &Path) -> Result<(), Fail> {
625    if !path.is_dir() {
626        return Err(Fail::Permanent(format!(
627            "dataset source root {} is not a readable directory — \
628             provision it (mount or create it), point `data_path:` \
629             somewhere that exists, or set `data_source:` so fdl mounts \
630             it here",
631            path.display(),
632        )));
633    }
634    std::fs::read_dir(path).map_err(|e| {
635        Fail::Permanent(format!(
636            "dataset source root {} cannot be listed: {e}",
637            path.display(),
638        ))
639    })?;
640    Ok(())
641}
642
643/// The mountpoint must exist before anything can be mounted on it.
644/// Creating it is provisioning's job when it sits outside `$HOME` (the
645/// `/flodl/data` convention needs one `sudo mkdir` per box), so failure
646/// names both ways out.
647fn ensure_mountpoint(dir: &Path) -> Result<(), Fail> {
648    if dir.is_dir() {
649        return Ok(());
650    }
651    std::fs::create_dir_all(dir).map_err(|e| {
652        Fail::Permanent(format!(
653            "mountpoint {} does not exist and cannot be created: {e} — \
654             create it once during provisioning (`sudo mkdir -p {} && \
655             sudo chown $USER {}`), or set `data_path:` to a directory \
656             this user owns",
657            dir.display(),
658            dir.display(),
659            dir.display(),
660        ))
661    })
662}
663
664// ---------------------------------------------------------------------------
665// Source specs
666// ---------------------------------------------------------------------------
667
668/// Parse a `data_source:` value. Unsupported schemes error loudly rather
669/// than being stubbed: a box that cannot reach its data must say so here,
670/// not fail mid-epoch.
671fn parse_source(spec: &str) -> Result<SshTarget, Fail> {
672    match split_scheme(spec) {
673        (Some("sshfs"), rest) => parse_ssh_target(rest).map_err(|why| {
674            Fail::Permanent(format!(
675                "invalid data_source `sshfs://{rest}` — {why}. Expected \
676                 `sshfs://[user@]host[:port]/abs/path` (or the scp spelling \
677                 `sshfs://[user@]host:/abs/path`)"
678            ))
679        }),
680        (Some(scheme), _) => Err(Fail::Permanent(format!(
681            "unsupported data_source scheme `{scheme}://` — fdl ships \
682             `sshfs://` today. A source another tool already mounted \
683             needs no scheme: name its path in `data_path:` instead"
684        ))),
685        (None, _) => Err(Fail::Permanent(format!(
686            "data_source `{spec}` names no transport — a source that is \
687             already mounted goes in `data_path:`; a source fdl should \
688             mount needs a scheme, e.g. \
689             `sshfs://user@host:/flodl/data`"
690        ))),
691    }
692}
693
694// ---------------------------------------------------------------------------
695// The mount
696// ---------------------------------------------------------------------------
697
698/// Establish the source mount. Read-only, and that is load-bearing: a
699/// rank reads the source root and never writes it (anything missing is
700/// acquired into the node-local cache instead), so `ro` puts the kernel
701/// behind an invariant that was previously only a convention. A box that
702/// needs a writable share mounts it during provisioning and declares a
703/// bare `data_path:`.
704///
705/// The mount outlives the attempt, and the run: it is provisioning state,
706/// which is what makes a re-dial cheap (the next attempt finds it and
707/// reuses it). `fusermount -u <mountpoint>` drops it.
708fn mount_sshfs(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Result<(), Fail> {
709    if !crate::util::system::has_command("sshfs") {
710        return Err(Fail::Permanent(format!(
711            "data_source needs sshfs, which is not installed — \
712             `sudo apt install sshfs` (or mount `{}` during provisioning \
713             and declare a bare `data_path:`)",
714            target.remote,
715        )));
716    }
717    let argv = sshfs_argv(target, mountpoint, ssh);
718    let out = Command::new(&argv[0])
719        .args(&argv[1..])
720        .output()
721        .map_err(|e| Fail::Permanent(format!("spawn sshfs: {e}")))?;
722    if !out.status.success() {
723        let stderr = String::from_utf8_lossy(&out.stderr);
724        return Err(Fail::Transient(format!(
725            "mounting `{}` at {} failed ({}): {}",
726            target.remote,
727            mountpoint.display(),
728            out.status,
729            stderr.trim(),
730        )));
731    }
732    // sshfs backgrounds itself only once the mount is established, so a
733    // zero exit that left nothing mounted means the far side went away
734    // mid-handshake.
735    if crate::probe::mounted_at(mountpoint).is_none() {
736        return Err(Fail::Transient(format!(
737            "sshfs reported success but nothing is mounted at {} — the \
738             far side likely dropped the connection",
739            mountpoint.display(),
740        )));
741    }
742    Ok(())
743}
744
745/// Assemble the sshfs command: user options first (OpenSSH takes the
746/// first value it sees per key, so the operator's win), then flodl's
747/// defaults. Returned as argv for testability.
748fn sshfs_argv(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Vec<String> {
749    let mut argv: Vec<String> = vec![
750        "sshfs".into(),
751        target.remote.clone(),
752        mountpoint.display().to_string(),
753    ];
754    let mut opt = |v: String| {
755        argv.push("-o".into());
756        argv.push(v);
757    };
758    if let Some(ssh) = ssh {
759        // The tunnel's own options and key: same box, same trust path,
760        // which that key has to actually permit (see `DataSpec::ssh`).
761        if let Some(warning) =
762            crate::cluster::batchmode_override_warning(&ssh.options, &target.remote)
763        {
764            eprintln!("{warning}");
765        }
766        for o in &ssh.options {
767            opt(o.clone());
768        }
769        if let Some(id) = &ssh.identity_file {
770            opt(format!("IdentityFile={id}"));
771        }
772    }
773    if let Some(port) = target.port {
774        opt(format!("port={port}"));
775    }
776    // ro: the source root is read-only by invariant. reconnect +
777    // ServerAlive: a dropped link comes back instead of wedging every
778    // read behind a dead channel. BatchMode: never hang on a prompt (a
779    // passphrase prompt inside a systemd unit wedges forever).
780    for o in [
781        "ro",
782        "reconnect",
783        "ServerAliveInterval=15",
784        "ServerAliveCountMax=3",
785        "BatchMode=yes",
786    ] {
787        opt(o.to_string());
788    }
789    argv
790}
791
792// ---------------------------------------------------------------------------
793// Node-local directories
794// ---------------------------------------------------------------------------
795
796/// Confirm the box can actually write where the data plane will write:
797/// the across-run dataset cache, and the within-run disk stage.
798///
799/// Neither is optional and neither is the source root — a read-only
800/// source is normal, an unwritable cache is fatal, and discovering that
801/// mid-epoch wastes the whole window.
802fn check_local_dirs(notes: &mut Vec<String>) -> Result<(), Fail> {
803    match std::env::var_os("HOME") {
804        Some(home) => {
805            let cache = PathBuf::from(home).join(CACHE_SUBPATH);
806            check_writable("dataset cache", &cache, true, notes)?;
807        }
808        None => notes.push(
809            "HOME is unset, so flodl will cache datasets under the temp \
810             directory — on a tmpfs that spends RAM, not disk. Set HOME, \
811             or pre-provision the source root."
812                .to_string(),
813        ),
814    }
815    check_writable("disk stage", &std::env::temp_dir(), false, notes)
816}
817
818/// One directory: create it if it is ours to create, prove it is
819/// writable by writing, then flag what would merely hurt.
820///
821/// Writability is proven, never inferred: permissions, ACLs, a
822/// read-only mount and a full filesystem all present differently in
823/// metadata and identically to a write.
824fn check_writable(
825    label: &str,
826    dir: &Path,
827    create: bool,
828    notes: &mut Vec<String>,
829) -> Result<(), Fail> {
830    if create {
831        std::fs::create_dir_all(dir).map_err(|e| {
832            Fail::Permanent(format!(
833                "{label} directory {} cannot be created: {e}",
834                dir.display(),
835            ))
836        })?;
837    } else if !dir.is_dir() {
838        return Err(Fail::Permanent(format!(
839            "{label} directory {} does not exist",
840            dir.display(),
841        )));
842    }
843    let probe = dir.join(format!(
844        ".fdl-prepare-{}-{}",
845        std::process::id(),
846        next_probe_id(),
847    ));
848    let written = std::fs::write(&probe, b"fdl prepare\n");
849    let _ = std::fs::remove_file(&probe);
850    written.map_err(|e| {
851        Fail::Permanent(format!(
852            "{label} directory {} is not writable: {e} — training stages \
853             data there, so it must be",
854            dir.display(),
855        ))
856    })?;
857
858    if let Some(fs_type) = crate::probe::detect_fs_type(dir)
859        && (fs_type == "tmpfs" || fs_type == "ramfs")
860    {
861        notes.push(format!(
862            "{label} directory {} is on {fs_type} (RAM-backed) — \
863                 staging there spends RAM, not disk",
864            dir.display(),
865        ));
866    }
867    if let Some(kib) = available_kib(dir)
868        && kib < LOW_SPACE_KIB
869    {
870        notes.push(format!(
871            "{label} directory {} has {} MiB free — smaller than any \
872                 real corpus",
873            dir.display(),
874            kib / 1024,
875        ));
876    }
877    Ok(())
878}
879
880/// Free space in KiB via `df -Pk` (POSIX output: one line per
881/// filesystem, never wrapped). `None` when `df` is unavailable or its
882/// output does not parse — a missing number is not worth failing a join
883/// over.
884fn available_kib(dir: &Path) -> Option<u64> {
885    let out = Command::new("df").arg("-Pk").arg(dir).output().ok()?;
886    if !out.status.success() {
887        return None;
888    }
889    let text = String::from_utf8_lossy(&out.stdout);
890    text.lines()
891        .nth(1)?
892        .split_whitespace()
893        .nth(3)?
894        .parse::<u64>()
895        .ok()
896}
897
898/// Per-process counter for probe-file names: the pid alone collides
899/// between two threads checking the same directory.
900fn next_probe_id() -> u64 {
901    use std::sync::atomic::{AtomicU64, Ordering};
902    static NEXT: AtomicU64 = AtomicU64::new(0);
903    NEXT.fetch_add(1, Ordering::Relaxed)
904}
905
906/// Print what preparation found, under the name of the command that
907/// found it. Notes are advisory by construction — every fatal condition
908/// already returned a [`Fail`].
909pub fn print_notes(command: &str, notes: &[String]) {
910    for note in notes {
911        eprintln!("{}", style::dim(&format!("fdl {command}: {note}")));
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    /// Hardware-independent, and it caught a real bug: the gate first
920    /// treated every absence-explaining survey note as a verdict, so a
921    /// box with an unusable AMD iGPU beside two working NVIDIA cards
922    /// refused to dial. Findings explain an absence; they do not create
923    /// one. (No spoofing: `FLODL_TESTING_GPU_JSON` is process-global and
924    /// this test binary reads the survey from several tests at once —
925    /// spoof-driven cases belong in flodl-hw.)
926    #[test]
927    fn the_gpu_gate_blocks_exactly_when_there_is_no_usable_device() {
928        let usable = !flodl_hw::survey_visible().devices.is_empty();
929        assert_eq!(
930            check_gpu_stack().is_ok(),
931            usable,
932            "the gate must follow the device list, not the findings",
933        );
934    }
935
936    /// Hardware-independent the same way the GPU-gate test is: the
937    /// expectation is computed from the real box, so a GPU-less CI
938    /// runner asserts the pass-through and a real rig asserts the
939    /// refusal.
940    #[test]
941    fn a_variant_covering_none_of_the_offered_cards_is_refused() {
942        let dir = std::env::temp_dir().join(format!(
943            "fdl-prep-arch-{}-{}",
944            std::process::id(),
945            next_probe_id(),
946        ));
947        std::fs::create_dir_all(&dir).unwrap();
948        // `0.0` matches no real device arch, so any NVIDIA card on this
949        // box is uncovered by construction.
950        std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
951        let lt = (dir.clone(), "precompiled/cu128".to_string());
952        let nvidia_present = flodl_hw::survey_visible()
953            .devices
954            .iter()
955            .any(|d| d.vendor == flodl_hw::GpuVendor::Nvidia);
956        match check_arch_coverage(&lt, None) {
957            Err(err) => {
958                assert!(nvidia_present, "refused with no matching device: {err:?}");
959                assert!(
960                    err.is_permanent(),
961                    "kernels do not grow by waiting: {err:?}"
962                );
963                assert!(err.message().contains("no kernel image"), "got: {err:?}");
964            }
965            Ok(()) => assert!(
966                !nvidia_present,
967                "an NVIDIA card offered against archs `0.0` must be refused",
968            ),
969        }
970        // A CPU variant, and a variant with no `.arch` metadata, gate
971        // nothing — probe owns the missing-metadata complaint.
972        assert!(check_arch_coverage(&(dir.clone(), "precompiled/cpu".into()), None).is_ok());
973        std::fs::remove_file(dir.join(".arch")).unwrap();
974        assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), None).is_ok());
975        // An empty offer gates nothing either: the device scope means
976        // this box deliberately offers none of that vendor's cards.
977        std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
978        assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), Some(&[])).is_ok());
979        let _ = std::fs::remove_dir_all(&dir);
980    }
981
982    #[test]
983    fn the_sshfs_scheme_reaches_the_shared_grammar() {
984        // The spellings themselves are `crate::spec`'s tests; this is the
985        // wrapper's job — dispatch on the scheme, hand back a target.
986        assert_eq!(
987            parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap(),
988            SshTarget {
989                remote: "flodl@exa:/flodl/data".into(),
990                port: Some(2222)
991            },
992        );
993    }
994
995    #[test]
996    fn a_published_manifest_outranks_the_boxs_own_recipe() {
997        // The point of a manifest: everything in it belongs to the RUN, and
998        // a cohort where one box disagrees about the binary is not a
999        // cohort.
1000        let local = SourceSpec {
1001            from: "rsync://ctrl:/srv/run/tree",
1002            cwd: Some("stale"),
1003            build: Some("stale-build"),
1004            bin: Some("stale-bin"),
1005            ssh: None,
1006        };
1007        let manifest = Manifest {
1008            cwd: Some("ddp-bench".into()),
1009            build: Some("cargo build --release".into()),
1010            bin: "target/release/ddp-bench".into(),
1011            ..Manifest::default()
1012        };
1013        let recipe = merge_manifest(&local, Some(&manifest)).unwrap();
1014        assert_eq!(recipe.cwd, Some("ddp-bench"));
1015        assert_eq!(recipe.build, Some("cargo build --release"));
1016        assert_eq!(recipe.bin, "target/release/ddp-bench");
1017    }
1018
1019    #[test]
1020    fn a_manifest_that_says_nothing_leaves_the_local_answer_standing() {
1021        // A hand-driven box needs no publish at all, and a manifest that
1022        // omits a field is not an instruction to forget the local one.
1023        let local = SourceSpec {
1024            from: "file:///mnt/rdl",
1025            cwd: Some("ddp-bench"),
1026            build: Some("./ci/node-build.sh"),
1027            bin: Some("target/release/x"),
1028            ssh: None,
1029        };
1030        let bare = Manifest {
1031            bin: "target/release/y".into(),
1032            ..Manifest::default()
1033        };
1034        let recipe = merge_manifest(&local, Some(&bare)).unwrap();
1035        assert_eq!(recipe.cwd, Some("ddp-bench"));
1036        assert_eq!(recipe.build, Some("./ci/node-build.sh"));
1037        assert_eq!(recipe.bin, "target/release/y");
1038
1039        let recipe = merge_manifest(&local, None).unwrap();
1040        assert_eq!(recipe.bin, "target/release/x");
1041    }
1042
1043    #[test]
1044    fn no_manifest_and_no_local_artifact_waits_rather_than_stopping() {
1045        // This is also the window a publish opens on purpose: it clears the
1046        // manifest before it touches the tree, so a box dialing mid-publish
1047        // must come back rather than train something unvalidated.
1048        let local = SourceSpec {
1049            from: "rsync://ctrl:/srv/run/tree",
1050            ..Default::default()
1051        };
1052        let err = merge_manifest(&local, None).unwrap_err();
1053        assert!(!err.is_permanent(), "the fix is a publish away: {err:?}");
1054        assert!(err.message().contains("fdl publish"), "got: {err:?}");
1055    }
1056
1057    #[test]
1058    fn a_failed_build_is_classed_by_whether_the_toolkit_could_explain_it() {
1059        let dir = std::env::temp_dir().join(format!(
1060            "fdl-prep-toolkit-{}-{}",
1061            std::process::id(),
1062            next_probe_id(),
1063        ));
1064        std::fs::create_dir_all(&dir).unwrap();
1065        let fail = |variant: &str| {
1066            let libtorch = (dir.clone(), variant.to_string());
1067            build_source(
1068                &Recipe {
1069                    cwd: None,
1070                    build: Some("exit 3"),
1071                    bin: "x",
1072                },
1073                &dir,
1074                Some(&libtorch),
1075                &mut Vec::new(),
1076            )
1077            .unwrap_err()
1078        };
1079        // A GPU variant: the verdict follows whether this box actually
1080        // has the toolkit (the check probes the real filesystem, so the
1081        // expectation is computed, not assumed — a ROCm rig running this
1082        // suite has the headers and must stay on the transient side).
1083        let err = fail("precompiled/rocm70");
1084        match crate::util::requirements::toolkit_gap(flodl_hw::GpuVendor::Amd) {
1085            Some(gap) => {
1086                assert!(
1087                    err.is_permanent(),
1088                    "waiting cannot install a package: {err:?}"
1089                );
1090                assert!(
1091                    err.message().contains(&gap.install),
1092                    "the fix must be named: {err:?}"
1093                );
1094            }
1095            None => assert!(
1096                !err.is_permanent(),
1097                "toolkit present, so a compile error stays a push away: {err:?}"
1098            ),
1099        }
1100        // A CPU variant wants no toolkit, so the failure stays transient
1101        // whatever this box has installed.
1102        let err = fail("precompiled/cpu");
1103        assert!(!err.is_permanent(), "got: {err:?}");
1104        let _ = std::fs::remove_dir_all(&dir);
1105    }
1106
1107    #[test]
1108    fn a_source_spec_rejects_every_broken_shape_permanently() {
1109        for spec in [
1110            "/flodl/data",             // no scheme: belongs in data_path
1111            "smb://server/share",      // scheme we do not ship
1112            "sshfs://exa",             // no path
1113            "sshfs://exa:banana/data", // port that is not a number
1114            "sshfs://:/flodl/data",    // empty host
1115            "sshfs://exa:/",           // root is not a source root
1116        ] {
1117            let err = parse_source(spec).unwrap_err();
1118            assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
1119            // Whatever the shape, the message names the field the
1120            // operator has to go and fix.
1121            assert!(err.message().contains("data_source"), "{spec}: {err:?}");
1122        }
1123    }
1124
1125    #[test]
1126    fn a_bare_path_names_the_field_it_belongs_in() {
1127        // The one-field grammar would have read this as "already
1128        // mounted"; the two-field split says where that goes.
1129        let err = parse_source("/flodl/data").unwrap_err();
1130        assert!(err.message().contains("data_path:"), "got: {err:?}");
1131    }
1132
1133    #[test]
1134    fn sshfs_argv_puts_user_options_before_the_defaults() {
1135        let ssh = SshConfig {
1136            target: Some("ctrl".into()),
1137            port: Some(2222),
1138            user: Some("join-user".into()),
1139            identity_file: Some("/etc/flodl/join_key".into()),
1140            options: vec!["ServerAliveInterval=5".into()],
1141        };
1142        let target = parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap();
1143        let argv = sshfs_argv(&target, Path::new("/flodl/data"), Some(&ssh));
1144        assert_eq!(argv[0], "sshfs");
1145        assert_eq!(argv[1], "flodl@exa:/flodl/data");
1146        assert_eq!(argv[2], "/flodl/data");
1147        // First -o value wins in OpenSSH: the user's override must
1148        // appear before flodl's default of the same key.
1149        let user_pos = argv
1150            .iter()
1151            .position(|a| a == "ServerAliveInterval=5")
1152            .unwrap();
1153        let default_pos = argv
1154            .iter()
1155            .position(|a| a == "ServerAliveInterval=15")
1156            .unwrap();
1157        assert!(user_pos < default_pos);
1158        assert!(argv.contains(&"IdentityFile=/etc/flodl/join_key".to_string()));
1159        // The port rides the SOURCE spec, not the tunnel block: they can
1160        // be different hosts.
1161        assert!(argv.contains(&"port=2222".to_string()));
1162        assert!(argv.contains(&"BatchMode=yes".to_string()));
1163        // Read-only is not optional — it is the source-root invariant.
1164        assert!(argv.contains(&"ro".to_string()));
1165    }
1166
1167    #[test]
1168    fn sshfs_argv_without_an_ssh_block_still_carries_the_defaults() {
1169        let target = parse_source("sshfs://exa/data").unwrap();
1170        let argv = sshfs_argv(&target, Path::new("/mnt/d"), None);
1171        assert!(argv.contains(&"ro".to_string()));
1172        assert!(argv.contains(&"reconnect".to_string()));
1173        assert!(!argv.iter().any(|a| a.starts_with("IdentityFile")));
1174        assert!(!argv.iter().any(|a| a.starts_with("port=")));
1175    }
1176
1177    #[test]
1178    fn no_data_fields_prepares_nothing() {
1179        let mut notes = Vec::new();
1180        let got = resolve_data_root(&DataSpec::default(), &mut notes).unwrap();
1181        assert_eq!(
1182            got, None,
1183            "a run that never mentions data must ship nothing"
1184        );
1185        assert!(notes.is_empty());
1186    }
1187
1188    #[test]
1189    fn a_relative_declared_path_is_shipped_absolute() {
1190        // The value reaches this host's ranks, so a relative path would
1191        // resolve against whatever each reader's cwd happens to be.
1192        let cwd = std::env::current_dir().unwrap();
1193        let name = format!("fdl-prep-rel-{}-{}", std::process::id(), next_probe_id());
1194        let dir = cwd.join(&name);
1195        std::fs::create_dir_all(&dir).unwrap();
1196        let spec = DataSpec {
1197            path: Some(&name),
1198            ..Default::default()
1199        };
1200        let got = resolve_data_root(&spec, &mut Vec::new()).unwrap();
1201        let _ = std::fs::remove_dir_all(&dir);
1202        assert_eq!(got, Some(dir));
1203    }
1204
1205    #[test]
1206    fn a_declared_path_is_verified_and_returned() {
1207        let dir = std::env::temp_dir().join(format!(
1208            "fdl-prep-src-{}-{}",
1209            std::process::id(),
1210            next_probe_id(),
1211        ));
1212        std::fs::create_dir_all(&dir).unwrap();
1213        let path = dir.display().to_string();
1214        let mut notes = Vec::new();
1215        let spec = DataSpec {
1216            path: Some(&path),
1217            ..Default::default()
1218        };
1219        assert_eq!(
1220            resolve_data_root(&spec, &mut notes).unwrap(),
1221            Some(dir.clone()),
1222        );
1223        let _ = std::fs::remove_dir_all(&dir);
1224    }
1225
1226    #[test]
1227    fn a_declared_path_that_is_not_there_is_permanent() {
1228        let missing = std::env::temp_dir()
1229            .join("fdl-prep-absent-do-not-create")
1230            .display()
1231            .to_string();
1232        let spec = DataSpec {
1233            path: Some(&missing),
1234            ..Default::default()
1235        };
1236        let err = resolve_data_root(&spec, &mut Vec::new()).unwrap_err();
1237        assert!(err.is_permanent(), "got: {err:?}");
1238        // Every fix, named: provision it, repoint it, or let fdl mount it.
1239        assert!(err.message().contains("data_source:"), "got: {err:?}");
1240    }
1241
1242    #[test]
1243    fn a_readable_source_root_needs_no_write_permission() {
1244        // The invariant under test: ranks read the source root and never
1245        // write it, so read-only must pass. A root-running suite can
1246        // write anywhere, so this asserts the CHECK's shape rather than
1247        // trying to build an unwritable directory.
1248        let dir = std::env::temp_dir().join(format!(
1249            "fdl-prep-ro-{}-{}",
1250            std::process::id(),
1251            next_probe_id(),
1252        ));
1253        std::fs::create_dir_all(&dir).unwrap();
1254        // `mut` is used only by the cfg(unix) arm below; on Windows
1255        // nothing writes it, and an unused_mut warning there is noise
1256        // rather than a finding.
1257        #[allow(unused_mut)]
1258        let mut perms = std::fs::metadata(&dir).unwrap().permissions();
1259        #[cfg(unix)]
1260        {
1261            use std::os::unix::fs::PermissionsExt;
1262            perms.set_mode(0o555);
1263        }
1264        std::fs::set_permissions(&dir, perms).unwrap();
1265        assert!(verify_source_root(&dir).is_ok());
1266        #[allow(unused_mut)]
1267        let mut perms = std::fs::metadata(&dir).unwrap().permissions();
1268        #[cfg(unix)]
1269        {
1270            use std::os::unix::fs::PermissionsExt;
1271            perms.set_mode(0o755);
1272        }
1273        std::fs::set_permissions(&dir, perms).unwrap();
1274        let _ = std::fs::remove_dir_all(&dir);
1275    }
1276
1277    #[test]
1278    fn a_writable_directory_passes_and_leaves_no_probe_file_behind() {
1279        let dir = std::env::temp_dir().join(format!(
1280            "fdl-prep-w-{}-{}",
1281            std::process::id(),
1282            next_probe_id(),
1283        ));
1284        let mut notes = Vec::new();
1285        check_writable("test", &dir, true, &mut notes).unwrap();
1286        let leftovers: Vec<_> = std::fs::read_dir(&dir)
1287            .unwrap()
1288            .map(|e| e.unwrap().file_name())
1289            .collect();
1290        assert!(
1291            leftovers.is_empty(),
1292            "probe file left behind: {leftovers:?}"
1293        );
1294        let _ = std::fs::remove_dir_all(&dir);
1295    }
1296
1297    #[test]
1298    fn a_missing_directory_we_must_not_create_is_permanent() {
1299        let dir = std::env::temp_dir().join("fdl-prep-absent-stage-dir");
1300        let err = check_writable("disk stage", &dir, false, &mut Vec::new()).unwrap_err();
1301        assert!(err.is_permanent(), "got: {err:?}");
1302    }
1303
1304    #[test]
1305    fn free_space_reads_back_for_a_directory_that_exists() {
1306        // Skip where `df` is absent rather than assert a number: the
1307        // point is that the parse lines up with real output.
1308        if !crate::util::system::has_command("df") {
1309            return;
1310        }
1311        let kib = available_kib(&std::env::temp_dir());
1312        assert!(kib.is_some_and(|k| k > 0), "got: {kib:?}");
1313    }
1314}