Skip to main content

flodl_cli/
source.rs

1//! The training source: a spec, a local tree, a build.
2//!
3//! A box that compiles its own training binary links against the exact
4//! libtorch it holds, which is what makes the ABI match by construction
5//! rather than by manifest discipline. It needs the tree on LOCAL disk
6//! first: cargo fingerprints by stat'ing every source file on every
7//! invocation, so building over a network mount pays that latency
8//! thousands of times before a line compiles, and the attribute caching
9//! that would fix the latency makes cargo miss real changes and hand
10//! back a stale binary.
11//!
12//! So a mount is a transport for the fetch, never a compile location,
13//! and every spec lands the same way: materialise into a local
14//! directory, then build there. One code path, which is why the dev loop
15//! is exercised by the production path instead of being a second mode.
16//!
17//! **The fetch must preserve mtimes.** A copy that stamps every file
18//! fresh makes cargo rebuild everything, so the loop silently degrades
19//! to cold builds while still looking incremental. `rsync -a` preserves
20//! them; a git fetch plus checkout only writes what changed. Both
21//! behave, and nothing else here is allowed to.
22
23use std::path::{Path, PathBuf};
24use std::process::Command;
25
26use serde::{Deserialize, Serialize};
27
28use crate::config::SshConfig;
29use crate::prepare::Fail;
30use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
31
32/// Paths the fetch skips, and `--delete` leaves an excluded path on the
33/// receiver alone, which is what keeps a local build alive across a
34/// refetch and so keeps the loop incremental.
35///
36/// `target/` and `.git/` are deliberately NOT anchored to the transfer
37/// root: cargo writes into the target dir of whichever manifest it built,
38/// so a `cwd:` naming a subdirectory (a workspace-excluded crate, say)
39/// puts the build under `<cwd>/target/` and an anchored `/target/` would
40/// protect the wrong one — the refetch then deletes the build every dial
41/// and every dial is a cold one, wearing an incremental costume. Found
42/// exactly that way, on a box, with a two-line rehearsal that a passing
43/// unit suite had nothing to say about.
44///
45/// `libtorch/` stays anchored, because it is the fdl project convention
46/// for one specific directory and a user's tree may legitimately carry
47/// its own `vendor/libtorch/` full of source.
48const RSYNC_EXCLUDES: [&str; 3] = ["target/", "libtorch/", ".git/"];
49
50/// The one exclude that means the project root and not any directory of
51/// that name. Kept beside its list so the asymmetry is visible.
52const ROOT_ANCHORED: [&str; 1] = ["libtorch/"];
53
54/// The default build recipe. A crate that builds locally already carries
55/// its `Cargo.toml`, its lockfile and its `rust-toolchain.toml` if the
56/// operator pinned one, so the recipe is usually just this — and a
57/// project that needs more (a feature flag, a workspace member, a
58/// script) says so rather than having fdl guess.
59const DEFAULT_BUILD: &str = "cargo build --release";
60
61/// Where a source tree comes from.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Source {
64    /// A directory on this box: a mount, a second disk, a checkout the
65    /// operator placed. Copied to local disk rather than built in place.
66    Local(PathBuf),
67    /// A working tree pulled over ssh. The one transport that carries
68    /// uncommitted work, which is what a training crate that lives in no
69    /// repo at all needs.
70    Rsync(SshTarget),
71    /// A checkout at a pinned ref.
72    Git { url: String, git_ref: String },
73}
74
75/// A built training binary and the directory it expects as cwd.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Built {
78    pub bin: PathBuf,
79    pub cwd: PathBuf,
80}
81
82/// File name of the run manifest, at the root of a published tree.
83pub const MANIFEST_FILE: &str = ".fdl-run.yml";
84
85/// The controller's answer to "what is this run", written beside the
86/// source it published and read by every box that fetches it.
87///
88/// It exists because a worker's own config is the wrong place for
89/// anything that changes per run. `args` is the sharp case: they must
90/// match the run, since rank children re-enter the binary with them, so a
91/// standing fleet carrying its own copy trains the next run with the
92/// previous one's hyperparameters. Everything stable for a box (its
93/// credentials, its libtorch policy, where to pull from) stays local;
94/// everything that belongs to the *run* comes from here.
95///
96/// **Its presence is the publish's commit point.** `fdl publish` removes
97/// it before it touches the tree and writes it only once the build has
98/// passed, so a box that dials mid-publish, or after a publish whose
99/// build failed, finds no manifest and waits for the next dial rather
100/// than training something unvalidated.
101#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(deny_unknown_fields)]
103pub struct Manifest {
104    /// Project directory inside the tree; `None` = its root.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub cwd: Option<String>,
107    /// Build recipe; `None` = the default cargo release build.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub build: Option<String>,
110    /// Artifact, relative to `cwd`.
111    pub bin: String,
112    /// The binary's own arguments.
113    #[serde(default)]
114    pub args: Vec<String>,
115    /// Where the controller got this tree, for provenance.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub origin: Option<String>,
118    /// `rustc -V` on the controller when it built this, ADVISORY. A
119    /// mismatch is worth reporting and not worth enforcing: every box
120    /// compiles its own binary, cohort agreement is about model
121    /// structure, and a toolchain too old fails loudly at compile time
122    /// anyway. Enforcing it would cost a toolchain install per box and
123    /// buy what a warning already gives.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub rustc: Option<String>,
126    /// Unix seconds at publish, so a box can say how old its run is.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub published_epoch: Option<u64>,
129    /// Identity of this publish, a fresh nonce every time (hex). It
130    /// rides each worker's join hello, and the window refuses a cohort
131    /// whose members hold different ids — two boxes that fetched across
132    /// a publish boundary would train two different runs as one world.
133    /// A nonce rather than a content hash on purpose: the common
134    /// re-publish is "same args, new code", which a manifest hash would
135    /// call identical, and hashing the tree buys the same answer for
136    /// the price of reading every file. `None` (an old manifest, a
137    /// hand-built tree) gates nothing.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub run: Option<String>,
140    /// False when the publish skipped its build gate (`--no-build`), so a
141    /// worker can say out loud that nothing has compiled this tree yet.
142    #[serde(default)]
143    pub built: bool,
144}
145
146impl Manifest {
147    /// Read the manifest at the root of `tree`. `Ok(None)` when there is
148    /// none, which is a state with meaning rather than an error: nobody
149    /// has published a run into this tree.
150    pub fn read(tree: &Path) -> Result<Option<Manifest>, Fail> {
151        let path = tree.join(MANIFEST_FILE);
152        let text = match std::fs::read_to_string(&path) {
153            Ok(t) => t,
154            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
155            Err(e) => {
156                return Err(Fail::Permanent(format!(
157                    "cannot read the run manifest {}: {e}",
158                    path.display(),
159                )));
160            }
161        };
162        serde_yaml_ng::from_str(&text)
163            .map(Some)
164            .map_err(|e| Fail::Permanent(format!("{} is not a run manifest: {e}", path.display())))
165    }
166
167    /// Write the manifest at the root of `tree`.
168    pub fn write(&self, tree: &Path) -> Result<(), Fail> {
169        let path = tree.join(MANIFEST_FILE);
170        let body = serde_yaml_ng::to_string(self)
171            .map_err(|e| Fail::Permanent(format!("cannot serialize the run manifest: {e}")))?;
172        std::fs::write(
173            &path,
174            format!(
175                "# Written by `fdl publish`. The controller is the authority \
176                 for a run:\n# a worker merges this over its own config, \
177                 because args must match the run\n# (rank children re-enter \
178                 the binary with them). Do not hand-edit — the next\n# \
179                 publish overwrites it, and its presence is what tells a \
180                 worker the run\n# is ready.\n{body}"
181            ),
182        )
183        .map_err(|e| {
184            Fail::Permanent(format!(
185                "cannot write the run manifest {}: {e}",
186                path.display()
187            ))
188        })
189    }
190
191    /// Remove the manifest, which is how a publish says "not ready yet".
192    pub fn remove(tree: &Path) -> Result<(), Fail> {
193        match std::fs::remove_file(tree.join(MANIFEST_FILE)) {
194            Ok(()) => Ok(()),
195            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
196            Err(e) => Err(Fail::Permanent(format!(
197                "cannot clear the run manifest: {e}"
198            ))),
199        }
200    }
201}
202
203/// Parse a source spec.
204///
205/// ```text
206/// file:///abs/path                      a directory on this box
207/// rsync://[user@]host[:port]:/abs/path  a working tree over ssh
208/// git+https://host/owner/repo#<ref>     a pinned checkout
209/// git+ssh://git@host/owner/repo#<ref>
210/// git+file:///abs/repo#<ref>            a local repository or mirror
211/// ```
212///
213/// The scheme names the TOOL rather than a wire protocol (`rsync://` the
214/// protocol is the port-873 daemon, which is not what this means), the
215/// same convention `data_source:` already uses for `sshfs://`.
216pub fn parse(spec: &str) -> Result<Source, Fail> {
217    let forms = "Expected `file:///abs/path`, \
218                 `rsync://[user@]host[:port]:/abs/path`, or \
219                 `git+https://host/owner/repo#<tag|branch|sha>`";
220    match split_scheme(spec) {
221        (Some("file"), rest) => {
222            if !rest.starts_with('/') {
223                return Err(Fail::Permanent(format!(
224                    "invalid source `{spec}` — a `file://` path must be \
225                     absolute (three slashes: `file:///srv/train`). {forms}"
226                )));
227            }
228            Ok(Source::Local(PathBuf::from(rest)))
229        }
230        (Some("rsync"), rest) => parse_ssh_target(rest)
231            .map(Source::Rsync)
232            .map_err(|why| Fail::Permanent(format!("invalid source `{spec}` — {why}. {forms}"))),
233        // `git+file://` is a local repository or mirror, and it is also
234        // what makes this resolver testable without a network.
235        (Some(scheme), rest)
236            if scheme == "git+https" || scheme == "git+ssh" || scheme == "git+file" =>
237        {
238            parse_git(scheme, rest, spec, forms)
239        }
240        (Some(scheme), _) => Err(Fail::Permanent(format!(
241            "unsupported source scheme `{scheme}://` — {forms}"
242        ))),
243        (None, _) => Err(Fail::Permanent(format!(
244            "source `{spec}` names no transport — a directory already on \
245             this box is `file://` plus its absolute path. {forms}"
246        ))),
247    }
248}
249
250/// `<url>#<ref>`, and the ref is not optional.
251///
252/// `#` separates it rather than `@` because both alternatives are
253/// ambiguous in real specs: a ref may contain `/` (`refs/heads/x`,
254/// `feature/y`) and an ssh URL carries `git@host` before the path, so an
255/// `@` split picks the wrong side of one or the other. A missing ref
256/// would mean the remote's default branch, which floats, and a floating
257/// ref is not a pin.
258fn parse_git(scheme: &str, rest: &str, spec: &str, forms: &str) -> Result<Source, Fail> {
259    let transport = scheme.trim_start_matches("git+");
260    let (path, git_ref) = rest.split_once('#').ok_or_else(|| {
261        Fail::Permanent(format!(
262            "source `{spec}` names no ref — add `#<tag|branch|sha>`. \
263             Without one the remote's default branch decides what a box \
264             builds, which is not a pin: two boxes provisioned an hour \
265             apart would not agree. {forms}"
266        ))
267    })?;
268    if git_ref.is_empty() {
269        return Err(Fail::Permanent(format!(
270            "source `{spec}` ends at `#` with no ref. {forms}"
271        )));
272    }
273    if path.is_empty() {
274        return Err(Fail::Permanent(format!(
275            "source `{spec}` names no repository. {forms}"
276        )));
277    }
278    Ok(Source::Git {
279        url: format!("{transport}://{path}"),
280        git_ref: git_ref.to_string(),
281    })
282}
283
284/// Put the tree at `dest`, preserving mtimes. Idempotent by
285/// construction: both resolvers are incremental refreshes, so a re-dial
286/// costs the changed files and nothing else.
287///
288/// `dest` is fdl's directory to manage — `rsync --delete` and `git
289/// checkout --force` both make the tree match the spec, so an edit made
290/// there does not survive.
291pub fn materialize(
292    source: &Source,
293    dest: &Path,
294    ssh: Option<&SshConfig>,
295    notes: &mut Vec<String>,
296) -> Result<(), Fail> {
297    std::fs::create_dir_all(dest).map_err(|e| {
298        Fail::Permanent(format!(
299            "cannot create source directory {}: {e}",
300            dest.display()
301        ))
302    })?;
303    match source {
304        Source::Local(path) => {
305            if !path.is_dir() {
306                return Err(Fail::Permanent(format!(
307                    "source {} is not a readable directory — provision it, \
308                     or point `from:` somewhere that exists",
309                    path.display(),
310                )));
311            }
312            run_rsync(
313                &rsync_argv(&format!("{}/", path.display()), dest, None, None),
314                dest,
315            )?;
316            notes.push(format!(
317                "source: copied {} into {}",
318                path.display(),
319                dest.display()
320            ));
321        }
322        Source::Rsync(target) => {
323            let argv = rsync_argv(&format!("{}/", target.remote), dest, Some(target), ssh);
324            run_rsync(&argv, dest)?;
325            notes.push(format!(
326                "source: pulled {} into {}",
327                target.remote,
328                dest.display()
329            ));
330        }
331        Source::Git { url, git_ref } => {
332            run_git(url, git_ref, dest)?;
333            notes.push(format!(
334                "source: checked out {url} at {git_ref} in {}",
335                dest.display()
336            ));
337        }
338    }
339    Ok(())
340}
341
342/// Assemble the rsync command. `-a` is what preserves mtimes (and so
343/// what keeps cargo incremental); `--delete` is what makes a removed
344/// file actually disappear from the node instead of lingering as a stale
345/// module. Returned as argv for testability.
346fn rsync_argv(
347    src: &str,
348    dest: &Path,
349    target: Option<&SshTarget>,
350    ssh: Option<&SshConfig>,
351) -> Vec<String> {
352    let mut argv: Vec<String> = vec!["rsync".into(), "-a".into(), "--delete".into()];
353    for ex in RSYNC_EXCLUDES {
354        let anchor = if ROOT_ANCHORED.contains(&ex) { "/" } else { "" };
355        argv.push(format!("--exclude={anchor}{ex}"));
356    }
357    if let Some(target) = target {
358        // One `-e` string, which rsync word-splits itself, so the ssh
359        // hop's own port / key / options ride along on the same trust
360        // path the tunnel uses. Word-split means a key path containing a
361        // space cannot travel this way; ssh_config on the box is the
362        // answer there, not more quoting.
363        let mut ssh_cmd = String::from("ssh");
364        if let Some(port) = target.port {
365            ssh_cmd.push_str(&format!(" -p {port}"));
366        }
367        if let Some(ssh) = ssh {
368            if let Some(id) = &ssh.identity_file {
369                ssh_cmd.push_str(&format!(" -i {id}"));
370            }
371            for opt in &ssh.options {
372                ssh_cmd.push_str(&format!(" -o {opt}"));
373            }
374        }
375        // Never hang on a prompt: a passphrase prompt inside a systemd
376        // unit wedges forever.
377        ssh_cmd.push_str(" -o BatchMode=yes");
378        argv.push("-e".into());
379        argv.push(ssh_cmd);
380    }
381    argv.push(src.to_string());
382    argv.push(format!("{}/", dest.display()));
383    argv
384}
385
386fn run_rsync(argv: &[String], dest: &Path) -> Result<(), Fail> {
387    if !crate::util::system::has_command("rsync") {
388        return Err(Fail::Permanent(
389            "a source spec needs rsync, which is not installed — \
390             `sudo apt install rsync` (it is what preserves mtimes, so \
391             cargo stays incremental instead of rebuilding everything \
392             every dial)"
393                .to_string(),
394        ));
395    }
396    let out = Command::new(&argv[0])
397        .args(&argv[1..])
398        .output()
399        .map_err(|e| Fail::Permanent(format!("spawn rsync: {e}")))?;
400    if !out.status.success() {
401        // Transient on purpose, the same call slice C's tunnel makes: a
402        // far side that is down and a path or key that is wrong are not
403        // distinguishable from here, and a wrong one keeps saying so
404        // loudly once a backoff.
405        return Err(Fail::Transient(format!(
406            "fetching the source into {} failed ({}): {} — check the \
407             remote path, the key, and whether that key's forced command \
408             permits rsync (a join key guardrailed with \
409             `command=\"/usr/sbin/nologin\"` does not)",
410            dest.display(),
411            out.status,
412            String::from_utf8_lossy(&out.stderr).trim(),
413        )));
414    }
415    Ok(())
416}
417
418/// Fetch a pinned ref into `dest`, shallow.
419///
420/// `git init` + `git fetch <url> <ref>` + `git checkout FETCH_HEAD`
421/// rather than `clone --branch`: one path covers a tag, a branch AND a
422/// bare commit, every step is idempotent, and no named remote means no
423/// remote bookkeeping to keep in sync when the spec changes.
424fn run_git(url: &str, git_ref: &str, dest: &Path) -> Result<(), Fail> {
425    if !crate::util::system::has_command("git") {
426        return Err(Fail::Permanent(
427            "a `git+` source spec needs git, which is not installed — \
428             `sudo apt install git`"
429                .to_string(),
430        ));
431    }
432    let dest_s = dest.display().to_string();
433    git(&["init", "--quiet", &dest_s], "initialise")?;
434    // Shallow: a node builds a tree, it does not browse history.
435    let fetch = git_output(&[
436        "-C", &dest_s, "fetch", "--quiet", "--depth", "1", url, git_ref,
437    ]);
438    match fetch {
439        Ok(()) => {}
440        Err(stderr) => {
441            // A bare commit sha can only be fetched when the server
442            // allows unadvertised objects (`uploadpack.allowReachableSHA1InWant`).
443            // Naming that beats falling back to a full clone, which on a
444            // metered box is the cost this whole path exists to avoid.
445            if stderr.contains("unadvertised object") || stderr.contains("allow request for") {
446                return Err(Fail::Permanent(format!(
447                    "the server refused a shallow fetch of `{git_ref}` \
448                     ({url}): fetching a bare commit needs \
449                     `uploadpack.allowReachableSHA1InWant` on the remote. \
450                     Name a tag or branch instead, or push the commit to \
451                     a ref. ({stderr})"
452                )));
453            }
454            return Err(Fail::Transient(format!(
455                "fetching {url} at `{git_ref}` failed: {stderr}"
456            )));
457        }
458    }
459    // --force: the tree is fdl's to manage, so a previous spec's files
460    // give way. It does not touch what is untracked, which is what keeps
461    // the local `target/` (and its incremental state) alive.
462    git(
463        &[
464            "-C",
465            &dest_s,
466            "checkout",
467            "--quiet",
468            "--detach",
469            "--force",
470            "FETCH_HEAD",
471        ],
472        "check out",
473    )?;
474    Ok(())
475}
476
477fn git(args: &[&str], what: &str) -> Result<(), Fail> {
478    git_output(args).map_err(|stderr| {
479        Fail::Permanent(format!("git failed to {what} the source tree: {stderr}"))
480    })
481}
482
483/// Run git, returning its stderr on failure so callers can class it.
484fn git_output(args: &[&str]) -> Result<(), String> {
485    let out = Command::new("git")
486        .args(args)
487        .output()
488        .map_err(|e| format!("spawn git: {e}"))?;
489    if out.status.success() {
490        return Ok(());
491    }
492    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
493    Err(if stderr.is_empty() {
494        format!("exited {}", out.status)
495    } else {
496        stderr
497    })
498}
499
500/// The environment a build recipe gets: the same names an `fdl.yml`
501/// `commands.run` line already relies on, so a recipe that works there
502/// works here.
503///
504/// What it deliberately does NOT contain is a feature flag fdl chose.
505/// `cuda` and `rocm` are this repo's feature names; a user's crate pins
506/// its flodl features in its own manifest and may expose neither, so the
507/// vendor is handed over as `$FDL_GPU_FEATURE` for a recipe to use or
508/// ignore.
509pub fn build_env(libtorch: Option<&(PathBuf, String)>) -> Vec<(String, String)> {
510    let Some((dir, variant)) = libtorch else {
511        return Vec::new();
512    };
513    let lib = dir.join("lib").display().to_string();
514    let vendor = crate::libtorch::detect::variant_vendor(variant);
515    vec![
516        // What flodl-sys/build.rs reads to find headers and libraries.
517        ("LIBTORCH_PATH".to_string(), dir.display().to_string()),
518        // The vendor's cargo feature, so a recipe can say
519        // `--features "$FDL_GPU_FEATURE"` instead of naming a vendor.
520        // EMPTY for a CPU variant (cargo accepts `--features ""`), and
521        // deliberately NOT `fdl run`'s legacy `cuda` fallback: the
522        // publish gate's whole cheap-mode story is "a CPU libtorch is
523        // enough", and the cuda fallback would make the recommended
524        // recipe build `--features cuda` against a CPU libtorch — a
525        // gate that always fails. One quoted recipe line now serves the
526        // CPU gate and both worker vendors. (Prebuild answers the same
527        // "" for cpu via `variant_feature`, so this is the derivation's
528        // majority spelling, not a third one.)
529        (
530            "FDL_GPU_FEATURE".to_string(),
531            vendor
532                .map(|v| v.cargo_feature().to_string())
533                .unwrap_or_default(),
534        ),
535        // Build scripts and the linker both want to find the libs, and
536        // on ROCm the ordering is the difference between a working
537        // process and a segfault at the first GPU op.
538        (
539            "LD_LIBRARY_PATH".to_string(),
540            crate::libtorch::detect::ld_library_path_value(
541                vendor,
542                &lib,
543                &crate::libtorch::detect::local_rocm_lib_dir(),
544            ),
545        ),
546    ]
547}
548
549/// Build the tree and hand back the binary.
550///
551/// `cwd` is the project directory inside the tree (the default is the
552/// tree root) and governs both the build and the run, so it answers
553/// "where is the project in this tree" once. `cmd` is a shell line, so
554/// it can be a script committed beside the code: the recipe then travels
555/// with the source while its invocation stays in the box's config. `env`
556/// is what fdl resolved for it (libtorch, the vendor's cargo feature,
557/// the loader path).
558pub fn build(
559    tree: &Path,
560    cwd: Option<&str>,
561    cmd: Option<&str>,
562    bin: &str,
563    env: &[(String, String)],
564    notes: &mut Vec<String>,
565) -> Result<Built, Fail> {
566    let dir = run_recipe(tree, cwd, cmd, env, notes)?;
567    let path = dir.join(bin);
568    if !path.is_file() {
569        return Err(Fail::Permanent(format!(
570            "the build succeeded but `bin: {bin}` is not there ({}) — it \
571             is the artifact path relative to `cwd:`, e.g. \
572             `target/release/<name>`",
573            path.display(),
574        )));
575    }
576    Ok(Built {
577        bin: path,
578        cwd: dir,
579    })
580}
581
582/// Run the recipe for the compile alone, no artifact check — the shape
583/// an extra publish gate wants: its `CARGO_TARGET_DIR` points somewhere
584/// the `bin:` convention does not, and success IS its whole verdict.
585pub fn check_build(
586    tree: &Path,
587    cwd: Option<&str>,
588    cmd: Option<&str>,
589    env: &[(String, String)],
590    notes: &mut Vec<String>,
591) -> Result<(), Fail> {
592    run_recipe(tree, cwd, cmd, env, notes).map(|_| ())
593}
594
595/// The shared compile step: resolve the project dir, run the recipe in
596/// it with fdl's resolved env, classify the failure. Returns the dir so
597/// [`build`] can anchor its artifact check on it.
598fn run_recipe(
599    tree: &Path,
600    cwd: Option<&str>,
601    cmd: Option<&str>,
602    env: &[(String, String)],
603    notes: &mut Vec<String>,
604) -> Result<PathBuf, Fail> {
605    let dir = match cwd {
606        Some(sub) => tree.join(sub),
607        None => tree.to_path_buf(),
608    };
609    if !dir.is_dir() {
610        return Err(Fail::Permanent(format!(
611            "`cwd: {}` names no directory in the fetched source ({}) — it \
612             is a path inside the tree, not on this box",
613            cwd.unwrap_or(""),
614            dir.display(),
615        )));
616    }
617    let recipe = cmd.unwrap_or(DEFAULT_BUILD);
618    // Only the default recipe is known to need cargo. A custom one may
619    // be a script, a make target, anything.
620    if cmd.is_none() && !crate::util::system::has_command("cargo") {
621        return Err(Fail::Permanent(
622            "building the source needs cargo, which is not installed — \
623             install a toolchain (https://rustup.rs), or set `build:` to \
624             a recipe that does not need one"
625                .to_string(),
626        ));
627    }
628
629    notes.push(format!("source: building in {} — {recipe}", dir.display()));
630    let mut command = Command::new("sh");
631    command.args(["-c", recipe]).current_dir(&dir);
632    for (k, v) in env {
633        command.env(k, v);
634    }
635    let status = command
636        .status()
637        .map_err(|e| Fail::Permanent(format!("spawn `{recipe}`: {e}")))?;
638    if !status.success() {
639        // The fact, with no audience assumed: a worker and a publishing
640        // controller both land here and owe the operator different next
641        // steps, so each adds its own. Transient by default because the
642        // worker is the caller that re-dials, and a compile error is the
643        // most transient thing in that system — the fix is a push away,
644        // while exiting permanently would let the systemd recipe power a
645        // box off over a typo.
646        return Err(Fail::Transient(format!(
647            "the source does not build ({status}) — see the compiler \
648             output above"
649        )));
650    }
651    Ok(dir)
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn every_shipped_spelling_parses() {
660        assert_eq!(
661            parse("file:///srv/train").unwrap(),
662            Source::Local("/srv/train".into())
663        );
664        assert_eq!(
665            parse("rsync://flodl@exa:/home/op/train").unwrap(),
666            Source::Rsync(SshTarget {
667                remote: "flodl@exa:/home/op/train".into(),
668                port: None
669            }),
670        );
671        assert_eq!(
672            parse("rsync://exa:2222/home/op/train").unwrap(),
673            Source::Rsync(SshTarget {
674                remote: "exa:/home/op/train".into(),
675                port: Some(2222)
676            }),
677        );
678        // The documented grammar's own product, port and scp colon both:
679        assert_eq!(
680            parse("rsync://exa:2222:/home/op/train").unwrap(),
681            Source::Rsync(SshTarget {
682                remote: "exa:/home/op/train".into(),
683                port: Some(2222)
684            }),
685        );
686        assert_eq!(
687            parse("git+https://github.com/flodl-labs/flodl#0.7.0").unwrap(),
688            Source::Git {
689                url: "https://github.com/flodl-labs/flodl".into(),
690                git_ref: "0.7.0".into(),
691            },
692        );
693        // An ssh URL carries `git@host` and a ref may carry `/`, which is
694        // exactly why the separator is `#` and not `@`.
695        assert_eq!(
696            parse("git+ssh://git@github.com/me/train#feature/wip").unwrap(),
697            Source::Git {
698                url: "ssh://git@github.com/me/train".into(),
699                git_ref: "feature/wip".into(),
700            },
701        );
702    }
703
704    /// The git resolver against a real repository: a shallow fetch of a
705    /// ref, a checkout, and then the same tree refetched at a second ref
706    /// to prove the incremental path works rather than only the first
707    /// clone. `git+file://` is what makes this reachable without a
708    /// network, which is the reason that scheme exists.
709    #[test]
710    fn the_git_resolver_fetches_a_ref_and_then_moves_to_another() {
711        if !crate::util::system::has_command("git") {
712            return;
713        }
714        let base = std::env::temp_dir().join(format!("fdl-src-git-{}", std::process::id()));
715        let (origin, dest) = (base.join("origin"), base.join("dest"));
716        std::fs::create_dir_all(&origin).unwrap();
717        let git = |args: &[&str]| {
718            let out = Command::new("git")
719                .args(args)
720                .current_dir(&origin)
721                .env("GIT_AUTHOR_NAME", "fdl")
722                .env("GIT_AUTHOR_EMAIL", "fdl@example.com")
723                .env("GIT_COMMITTER_NAME", "fdl")
724                .env("GIT_COMMITTER_EMAIL", "fdl@example.com")
725                .output()
726                .unwrap();
727            assert!(
728                out.status.success(),
729                "git {args:?}: {}",
730                String::from_utf8_lossy(&out.stderr)
731            );
732        };
733        git(&["init", "--quiet"]);
734        std::fs::write(origin.join("main.rs"), "// one").unwrap();
735        git(&["add", "."]);
736        git(&["commit", "--quiet", "-m", "one"]);
737        git(&["tag", "v1"]);
738        std::fs::write(origin.join("main.rs"), "// two").unwrap();
739        git(&["add", "."]);
740        git(&["commit", "--quiet", "-m", "two"]);
741        git(&["tag", "v2"]);
742
743        let url = format!("git+file://{}", origin.display());
744        let at_v1 = parse(&format!("{url}#v1")).unwrap();
745        materialize(&at_v1, &dest, None, &mut Vec::new()).unwrap();
746        assert_eq!(
747            std::fs::read_to_string(dest.join("main.rs")).unwrap(),
748            "// one"
749        );
750
751        // A build output is untracked, so moving refs must not sweep it
752        // away — that is what keeps the loop incremental here too.
753        std::fs::create_dir_all(dest.join("target/release")).unwrap();
754        std::fs::write(dest.join("target/release/train"), "binary").unwrap();
755
756        let at_v2 = parse(&format!("{url}#v2")).unwrap();
757        materialize(&at_v2, &dest, None, &mut Vec::new()).unwrap();
758        assert_eq!(
759            std::fs::read_to_string(dest.join("main.rs")).unwrap(),
760            "// two"
761        );
762        assert!(
763            dest.join("target/release/train").is_file(),
764            "the checkout swept the build"
765        );
766
767        // A ref that does not exist must fail rather than land on
768        // whatever the remote's default branch happens to be.
769        let missing = parse(&format!("{url}#v9")).unwrap();
770        assert!(materialize(&missing, &dest, None, &mut Vec::new()).is_err());
771        let _ = std::fs::remove_dir_all(&base);
772    }
773
774    #[test]
775    fn a_broken_spec_is_permanent_and_names_the_forms() {
776        for spec in [
777            "/srv/train",                       // no transport
778            "file://srv/train",                 // relative file url
779            "smb://server/share",               // scheme we do not ship
780            "rsync://exa",                      // no remote path
781            "git+https://github.com/me/train",  // no ref: a floating default branch
782            "git+https://github.com/me/train#", // empty ref
783            "git+ssh://#0.7.0",                 // no repository
784        ] {
785            let err = parse(spec).unwrap_err();
786            assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
787            assert!(
788                err.message().contains("file:///") || err.message().contains("`#<"),
789                "{spec} should name the accepted forms: {err:?}",
790            );
791        }
792    }
793
794    #[test]
795    fn a_missing_ref_explains_why_a_default_branch_is_not_a_pin() {
796        let err = parse("git+https://github.com/me/train").unwrap_err();
797        assert!(err.message().contains("not a pin"), "got: {err:?}");
798    }
799
800    #[test]
801    fn rsync_argv_preserves_times_and_protects_the_target_dir() {
802        let argv = rsync_argv("/mnt/rdl/", Path::new("/home/op/.flodl/source"), None, None);
803        assert_eq!(argv[0], "rsync");
804        // -a is the mtime guarantee, and the whole loop rests on it.
805        assert!(argv.contains(&"-a".to_string()));
806        assert!(argv.contains(&"--delete".to_string()));
807        // UNANCHORED, and that is the whole point: a `cwd:` subdirectory
808        // holds its own target dir, and `/target/` would protect only the
809        // root one — every refetch would then delete the build.
810        assert!(argv.contains(&"--exclude=target/".to_string()));
811        assert!(!argv.contains(&"--exclude=/target/".to_string()));
812        // Anchored, because it names the project convention rather than
813        // any directory called libtorch.
814        assert!(argv.contains(&"--exclude=/libtorch/".to_string()));
815        // Trailing slashes on both ends: contents into contents, not a
816        // nested `source/rdl/`.
817        assert_eq!(argv[argv.len() - 2], "/mnt/rdl/");
818        assert_eq!(argv[argv.len() - 1], "/home/op/.flodl/source/");
819        // No `-e` without a remote: a local copy needs no ssh at all.
820        assert!(!argv.contains(&"-e".to_string()));
821    }
822
823    #[test]
824    fn rsync_argv_carries_the_ssh_hops_port_key_and_options() {
825        let ssh = SshConfig {
826            target: Some("exa".into()),
827            port: Some(22),
828            user: None,
829            identity_file: Some("/etc/flodl/join_key".into()),
830            options: vec!["StrictHostKeyChecking=accept-new".into()],
831        };
832        let target = SshTarget {
833            remote: "op@exa:/srv/train".into(),
834            port: Some(2222),
835        };
836        let argv = rsync_argv(
837            "op@exa:/srv/train/",
838            Path::new("/t"),
839            Some(&target),
840            Some(&ssh),
841        );
842        let e = argv
843            .iter()
844            .position(|a| a == "-e")
845            .expect("-e for a remote source");
846        let cmd = &argv[e + 1];
847        // The port comes from the SOURCE spec, not the tunnel block: they
848        // can be different hosts.
849        assert!(cmd.contains("-p 2222"), "got: {cmd}");
850        assert!(cmd.contains("-i /etc/flodl/join_key"), "got: {cmd}");
851        assert!(
852            cmd.contains("-o StrictHostKeyChecking=accept-new"),
853            "got: {cmd}"
854        );
855        assert!(cmd.contains("-o BatchMode=yes"), "got: {cmd}");
856    }
857
858    /// The two properties the whole loop rests on, exercised against the
859    /// real tool: an old mtime stays old (or cargo rebuilds the world
860    /// every dial), and a build under a `cwd:` subdirectory survives the
861    /// refetch (or every dial is a cold one wearing an incremental
862    /// costume). Skipped where rsync is absent rather than asserted
863    /// around, the same call the free-space test makes about `df`.
864    #[test]
865    fn a_refetch_keeps_an_old_mtime_and_a_nested_build() {
866        if !crate::util::system::has_command("rsync") {
867            return;
868        }
869        let base = std::env::temp_dir().join(format!("fdl-src-refetch-{}", std::process::id()));
870        let (src, dest) = (base.join("src"), base.join("dest"));
871        std::fs::create_dir_all(src.join("sub")).unwrap();
872        std::fs::write(src.join("sub/lib.rs"), "fn main() {}").unwrap();
873        std::fs::write(src.join("gone.txt"), "temporary").unwrap();
874        // Stamp the source old, so "did not touch it" is observable
875        // rather than inferred from two fresh timestamps agreeing.
876        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(86_400);
877        std::fs::File::options()
878            .write(true)
879            .open(src.join("sub/lib.rs"))
880            .unwrap()
881            .set_times(std::fs::FileTimes::new().set_modified(old))
882            .unwrap();
883
884        let source = Source::Local(src.clone());
885        materialize(&source, &dest, None, &mut Vec::new()).unwrap();
886        // A build lands under the subdirectory, where cargo puts it for a
887        // manifest that is not the tree root.
888        std::fs::create_dir_all(dest.join("sub/target/release")).unwrap();
889        std::fs::write(dest.join("sub/target/release/train"), "binary").unwrap();
890        std::fs::remove_file(src.join("gone.txt")).unwrap();
891
892        materialize(&source, &dest, None, &mut Vec::new()).unwrap();
893
894        assert!(
895            dest.join("sub/target/release/train").is_file(),
896            "the refetch deleted a nested build",
897        );
898        assert!(
899            !dest.join("gone.txt").exists(),
900            "--delete must drop a removed file"
901        );
902        let copied = std::fs::metadata(dest.join("sub/lib.rs"))
903            .unwrap()
904            .modified()
905            .unwrap();
906        let drift = copied.duration_since(old).unwrap_or_default();
907        assert!(
908            drift < std::time::Duration::from_secs(2),
909            "the fetch restamped the file ({drift:?} newer than the source)",
910        );
911        let _ = std::fs::remove_dir_all(&base);
912    }
913
914    #[test]
915    fn a_cwd_outside_the_fetched_tree_is_permanent() {
916        let tree = std::env::temp_dir().join("fdl-src-no-such-tree");
917        let err = build(&tree, Some("nope"), Some("true"), "x", &[], &mut Vec::new()).unwrap_err();
918        assert!(err.is_permanent(), "got: {err:?}");
919        assert!(err.message().contains("inside the tree"), "got: {err:?}");
920    }
921
922    #[test]
923    fn a_build_that_fails_is_transient_so_the_fleet_survives_a_typo() {
924        let dir = std::env::temp_dir().join(format!("fdl-src-build-{}", std::process::id()));
925        std::fs::create_dir_all(&dir).unwrap();
926        let err = build(&dir, None, Some("exit 3"), "bin", &[], &mut Vec::new()).unwrap_err();
927        assert!(
928            !err.is_permanent(),
929            "a compile error must not stop the box: {err:?}"
930        );
931        let _ = std::fs::remove_dir_all(&dir);
932    }
933
934    #[test]
935    fn a_build_that_produces_nothing_at_bin_is_permanent() {
936        // Config error, not a transient one: retrying cannot make the
937        // recipe write somewhere else.
938        let dir = std::env::temp_dir().join(format!("fdl-src-nobin-{}", std::process::id()));
939        std::fs::create_dir_all(&dir).unwrap();
940        let err = build(
941            &dir,
942            None,
943            Some("true"),
944            "target/release/x",
945            &[],
946            &mut Vec::new(),
947        )
948        .unwrap_err();
949        assert!(err.is_permanent(), "got: {err:?}");
950        assert!(err.message().contains("bin:"), "got: {err:?}");
951        let _ = std::fs::remove_dir_all(&dir);
952    }
953
954    #[test]
955    fn the_env_reaches_the_recipe_and_the_binary_is_returned() {
956        let dir = std::env::temp_dir().join(format!("fdl-src-env-{}", std::process::id()));
957        std::fs::create_dir_all(dir.join("sub")).unwrap();
958        let env = vec![("FDL_TEST_MARKER".to_string(), "ok".to_string())];
959        // The recipe writes the marker's value where `bin:` says, so a
960        // pass proves both the env and the cwd arrived.
961        let built = build(
962            &dir,
963            Some("sub"),
964            Some("printf %s \"$FDL_TEST_MARKER\" > out"),
965            "out",
966            &env,
967            &mut Vec::new(),
968        )
969        .unwrap();
970        assert_eq!(built.cwd, dir.join("sub"));
971        assert_eq!(built.bin, dir.join("sub").join("out"));
972        assert_eq!(std::fs::read_to_string(&built.bin).unwrap(), "ok");
973        let _ = std::fs::remove_dir_all(&dir);
974    }
975}