Skip to main content

flodl_cli/
publish.rs

1//! `fdl publish` — put a run where the fleet can pull it.
2//!
3//! The controller side of compiling on the node. It resolves a source
4//! spec into a served directory, builds it once, and writes the run
5//! manifest workers read. Chaining trainings on a standing fleet is then
6//! one command: publish again and every box picks the new run up on its
7//! next dial, with nothing to edit on any worker.
8//!
9//! **The build is validation, not an artifact.** One build gates the
10//! publish; each worker still compiles its own, because a controller
11//! producing binaries for N worker variants is the build matrix this
12//! design deleted. A gate needs no GPU libtorch either — compiling
13//! without a GPU feature against the cheap CPU variant catches user-code
14//! errors just as well — so the cost of having it on by default is
15//! rustup plus `fdl libtorch download --cpu`. What it buys is that a tree
16//! which cannot compile never reaches the fleet, where N boxes would each
17//! discover it separately, in logs nobody is watching.
18//!
19//! It proves the tree for the CONTROLLER's variant only. A break that
20//! exists solely under `--features rocm` passes a CUDA gate and lands on
21//! a worker; superset check, not a proof.
22
23use std::path::{Path, PathBuf};
24use std::process::Command;
25
26use crate::builtins::PublishArgs;
27use crate::context::Context;
28use crate::prepare::Fail;
29use crate::source::{self, Manifest};
30use crate::style;
31
32/// Served-directory name under the root, and the tree inside it. The
33/// manifest sits at the tree's root, so one rsync of `<served>/tree`
34/// carries both.
35const SERVED_SUBDIR: &str = "run";
36const TREE_SUBDIR: &str = "tree";
37
38/// Run `fdl publish`. `args_tail` is everything after a standalone `--`:
39/// the training binary's own arguments, which belong to the RUN and
40/// therefore to the manifest rather than to any worker's config.
41///
42/// A top-level `publish:` block in fdl.yml (or the active env overlay)
43/// supplies standing answers so re-publishing a run is one bare
44/// command; flags win field by field, and a `--` tail replaces the
45/// block's `args:` outright.
46pub fn run(cli: &PublishArgs, args_tail: Option<&[String]>) -> i32 {
47    let block = match load_publish_block() {
48        Ok(block) => block,
49        Err(e) => {
50            crate::cli_error!("{e}");
51            return 1;
52        }
53    };
54    let (cli, tail) = with_block_defaults(cli, args_tail, block);
55    match publish(&cli, tail.as_deref()) {
56        Ok((served, tree, manifest)) => {
57            if cli.json {
58                println!(
59                    "{}",
60                    serde_json::to_string_pretty(&report_value(&served, &tree, &manifest))
61                        .expect("a report value serializes"),
62                );
63            } else {
64                report(&served, &tree, &manifest);
65            }
66            0
67        }
68        Err(fail) => {
69            crate::cli_error!("{}", fail.message());
70            1
71        }
72    }
73}
74
75/// The top-level `publish:` block from the project config, honoring the
76/// active env overlay — the same walk `fdl join` does for its block.
77/// `Ok(None)` when no project (or no block) exists: flags then carry
78/// everything. A present-but-broken config is a loud error, never a
79/// silent fallback.
80fn load_publish_block() -> Result<Option<crate::config::PublishBlock>, String> {
81    let cwd =
82        std::env::current_dir().map_err(|e| format!("cannot read the current directory: {e}"))?;
83    let Some(config_path) = crate::config::find_project_config(&cwd) else {
84        return Ok(None);
85    };
86    let env_name = std::env::var("FDL_ENV")
87        .ok()
88        .filter(|s| !s.trim().is_empty());
89    let project = crate::config::load_project_with_env(&config_path, env_name.as_deref())
90        .map_err(|e| format!("cannot load {}: {e}", config_path.display()))?;
91    Ok(project.publish)
92}
93
94/// Fill flag gaps from the block. Flags win field by field; the `--`
95/// tail replaces `args:` outright, EVEN WHEN EMPTY — the args belong to
96/// the run, so "explicitly none" must be sayable. `--no-build` stays
97/// flag-only on purpose: a standing config that skips the gate would
98/// ship every future typo to the fleet.
99fn with_block_defaults(
100    cli: &PublishArgs,
101    args_tail: Option<&[String]>,
102    block: Option<crate::config::PublishBlock>,
103) -> (PublishArgs, Option<Vec<String>>) {
104    let block = block.unwrap_or_default();
105    let merged = PublishArgs {
106        source: cli.source.clone().or(block.source),
107        bin: cli.bin.clone().or(block.bin),
108        cwd: cli.cwd.clone().or(block.cwd),
109        build: cli.build.clone().or(block.build),
110        to: cli.to.clone().or(block.to),
111        no_build: cli.no_build,
112        identity: cli.identity.clone().or(block.identity),
113        json: cli.json,
114        gate: cli.gate.clone(),
115    };
116    let tail = match args_tail {
117        Some(t) => Some(t.to_vec()),
118        None => (!block.args.is_empty()).then_some(block.args),
119    };
120    (merged, tail)
121}
122
123fn publish(
124    cli: &PublishArgs,
125    args_tail: Option<&[String]>,
126) -> Result<(PathBuf, PathBuf, Manifest), Fail> {
127    let Some(spec) = cli.source.as_deref() else {
128        return Err(Fail::Permanent(
129            "fdl publish needs a source to publish, e.g. `fdl publish \
130             file:///home/op/my-train --bin target/release/my-train` — or \
131             a standing `publish:` block in fdl.yml carrying both"
132                .to_string(),
133        ));
134    };
135    let Some(bin) = cli.bin.as_deref() else {
136        return Err(Fail::Permanent(
137            "fdl publish needs `--bin <path relative to the project dir>` \
138             (or `publish.bin:` in fdl.yml) — it is what workers run, and \
139             it cannot be guessed (a workspace member's build lands in the \
140             WORKSPACE target/, not the member's)"
141                .to_string(),
142        ));
143    };
144
145    // Parse before touching anything. Clearing the manifest takes the
146    // fleet out of service until the build passes, and a spec with a typo
147    // in it must not cost that.
148    let source = source::parse(spec)?;
149
150    let served = match &cli.to {
151        Some(dir) => PathBuf::from(dir),
152        None => Context::global().root.join(SERVED_SUBDIR),
153    };
154    let tree = served.join(TREE_SUBDIR);
155    std::fs::create_dir_all(&tree).map_err(|e| {
156        Fail::Permanent(format!(
157            "cannot create the served directory {}: {e}",
158            tree.display()
159        ))
160    })?;
161
162    // Now clear it. Its presence is this command's commit point, so from
163    // here until the build passes a worker sees a tree with no run in it
164    // and waits for the next dial instead of training something nobody
165    // has compiled.
166    Manifest::remove(&tree)?;
167
168    let mut notes = Vec::new();
169    let result = (|| -> Result<Manifest, Fail> {
170        source::materialize(&source, &tree, cli.ssh_config().as_ref(), &mut notes)?;
171        let built = if cli.no_build {
172            notes.push(
173                "--no-build: nothing has compiled this tree, so the first \
174                 worker to fetch it is where a broken build will surface"
175                    .to_string(),
176            );
177            if !cli.gate.is_empty() {
178                notes.push(
179                    "--no-build also skips the --gate check-builds — they \
180                     are builds"
181                        .to_string(),
182                );
183            }
184            false
185        } else {
186            build_gate(&tree, cli, bin, &mut notes)?;
187            let root = Context::resolve().root;
188            for variant in &cli.gate {
189                check_gate_variant(&root, &tree, cli, variant, &mut notes)?;
190            }
191            true
192        };
193        Ok(Manifest {
194            cwd: cli.cwd.clone(),
195            build: cli.build.clone(),
196            bin: bin.to_string(),
197            args: args_tail.map(<[String]>::to_vec).unwrap_or_default(),
198            origin: Some(spec.to_string()),
199            rustc: rustc_version(),
200            published_epoch: unix_seconds(),
201            run: Some(run_nonce()),
202            built,
203        })
204    })();
205    crate::prepare::print_notes("publish", &notes);
206    let manifest = result?;
207    manifest.write(&tree)?;
208    Ok((served, tree, manifest))
209}
210
211/// Compile the tree once, against this box's own libtorch.
212fn build_gate(
213    tree: &Path,
214    cli: &PublishArgs,
215    bin: &str,
216    notes: &mut Vec<String>,
217) -> Result<(), Fail> {
218    let ctx = Context::resolve();
219    let libtorch = crate::libtorch::detect::active_variant(&ctx.root);
220    if libtorch.is_none() {
221        notes.push(format!(
222            "no active libtorch under {} — the gate builds without \
223             LIBTORCH_PATH, which anything linking flodl will refuse. \
224             `fdl libtorch download --cpu` is enough for a gate (it \
225             validates the tree, it does not ship the binary).",
226            ctx.root.display(),
227        ));
228    }
229    let env = source::build_env(libtorch.as_ref());
230    // A gate failure is not the worker-side "wait for the next publish":
231    // the operator is standing right here, so it is theirs to fix now.
232    source::build(
233        tree,
234        cli.cwd.as_deref(),
235        cli.build.as_deref(),
236        bin,
237        &env,
238        notes,
239    )
240    .map(|_| ())
241    .map_err(|e| {
242        Fail::Permanent(format!(
243            "{}. Nothing was published, so the fleet keeps running \
244                 whatever it had",
245            e.message(),
246        ))
247    })
248}
249
250/// One `--gate <variant>` check-build: the same recipe against a named
251/// libtorch variant, so a break that exists only under the other
252/// vendor's feature dies here instead of on a worker. Linking needs no
253/// GPU — a CPU-only controller proves both vendors this way — but a
254/// flodl-linking crate still needs the vendor's dev headers on this
255/// box: libtorch bundles runtime libraries, not headers, and flodl-sys'
256/// pre-flight fails the gate loudly with the exact package line.
257///
258/// The compile runs under its own `CARGO_TARGET_DIR` so each variant's
259/// incremental cache stays warm (a shared target/ would rebuild the
260/// world on every `LIBTORCH_PATH` flip) — which also moves the artifact
261/// away from the `bin:` convention, so success alone is the verdict
262/// (`source::check_build`).
263fn check_gate_variant(
264    root: &Path,
265    tree: &Path,
266    cli: &PublishArgs,
267    variant: &str,
268    notes: &mut Vec<String>,
269) -> Result<(), Fail> {
270    let dir = root.join("libtorch").join(variant);
271    if !dir.join("lib").is_dir() {
272        return Err(Fail::Permanent(format!(
273            "--gate {variant}: no libtorch at {} — `fdl libtorch download` \
274             can fetch it (a check-build needs no GPU, only the libraries \
275             to link against)",
276            dir.display(),
277        )));
278    }
279    let mut env = source::build_env(Some(&(dir, variant.to_string())));
280    env.push((
281        "CARGO_TARGET_DIR".to_string(),
282        format!("target/gate/{}", variant.replace('/', "-")),
283    ));
284    notes.push(format!("gate: check-building against {variant}"));
285    source::check_build(tree, cli.cwd.as_deref(), cli.build.as_deref(), &env, notes).map_err(|e| {
286        Fail::Permanent(format!(
287            "--gate {variant}: {}. Nothing was published, so the fleet \
288                 keeps running whatever it had",
289            e.message(),
290        ))
291    })
292}
293
294/// The report as data — the single source both renderers draw from, so
295/// the JSON twin cannot drift from the human text. `worker_specs`
296/// carries BOTH source-spec spellings because which one is right is a
297/// property of the serving key (plain ssh vs rrsync-guardrailed), which
298/// only the reader knows.
299fn report_value(served: &Path, tree: &Path, manifest: &Manifest) -> serde_json::Value {
300    let host = crate::cluster::resolve_local_hostname();
301    serde_json::json!({
302        "tree": tree.display().to_string(),
303        "served": served.display().to_string(),
304        "built": manifest.built,
305        "toolchain": manifest.rustc,
306        "args": manifest.args,
307        "run": manifest.run,
308        "origin": manifest.origin,
309        "published_epoch": manifest.published_epoch,
310        "host": host,
311        "worker_specs": {
312            "plain": format!("rsync://{}:{}", host, tree.display()),
313            "rrsync": format!("rsync://{host}:/{TREE_SUBDIR}"),
314        },
315    })
316}
317
318/// What the operator needs to hand a worker, and what the run now is.
319fn report(served: &Path, tree: &Path, manifest: &Manifest) {
320    println!();
321    println!("  published: {}", tree.display());
322    if !manifest.built {
323        println!("  build:     SKIPPED (--no-build)");
324    }
325    if let Some(rustc) = &manifest.rustc {
326        println!("  toolchain: {rustc} (advisory)");
327    }
328    if !manifest.args.is_empty() {
329        println!("  args:      {}", manifest.args.join(" "));
330    }
331    let host = crate::cluster::resolve_local_hostname();
332    if let Some(run) = &manifest.run {
333        println!("  run:       {run} (the join window refuses a cohort mixing ids)");
334    }
335    println!();
336    println!("  Workers pull it with a source spec pointing here. TWO spellings,");
337    println!("  and the key that serves the pull decides which — they do not mix:");
338    println!();
339    println!("    # a plain ssh key (no forced command): the absolute path");
340    println!("    join:");
341    println!("      source:");
342    println!("        from: rsync://{}:{}", host, tree.display());
343    println!();
344    println!(
345        "    # a guardrailed key, forced command=\"rrsync -ro {}\":",
346        served.display(),
347    );
348    println!("    # rrsync re-roots every requested path under its directory,");
349    println!("    # so the worker asks for /{TREE_SUBDIR} — the absolute path would");
350    println!("    # double-root and fail");
351    println!("    join:");
352    println!("      source:");
353    println!("        from: rsync://{host}:/{TREE_SUBDIR}");
354    println!();
355    println!(
356        "  {}",
357        style::dim(
358            "cwd / bin / build / args come from the manifest beside the \
359             tree, so a worker's own config carries only what is stable \
360             for that box. Re-publish to change the run; every box picks \
361             it up on its next dial."
362        ),
363    );
364    println!(
365        "  {}",
366        style::dim(&format!(
367            "Adjust `{host}` to how workers reach this box, and add \
368             `user@` when the serving key lives on a dedicated user \
369             (docs/ddp/02-cluster-guide.md has the key recipes)."
370        )),
371    );
372}
373
374/// `rustc -V`, for the manifest's advisory line. `None` when there is no
375/// toolchain here, which `--no-build` makes legitimate.
376fn rustc_version() -> Option<String> {
377    let out = Command::new("rustc").arg("-V").output().ok()?;
378    if !out.status.success() {
379        return None;
380    }
381    let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
382    (!v.is_empty()).then_some(v)
383}
384
385fn unix_seconds() -> Option<u64> {
386    std::time::SystemTime::now()
387        .duration_since(std::time::UNIX_EPOCH)
388        .ok()
389        .map(|d| d.as_secs())
390}
391
392/// A fresh 16-byte hex nonce per publish — the run's identity at the
393/// join window. Not a credential (it travels in a world-readable
394/// manifest), so the entropy bar is "two publishes never collide", not
395/// secrecy: OS entropy, and time+pid when even that fails — uniqueness
396/// survives the fallback, which is why the nonce keeps one while the
397/// wizard's token (a secret) refuses instead.
398fn run_nonce() -> String {
399    let mut bytes = [0u8; 16];
400    if getrandom::fill(&mut bytes).is_err() {
401        let seed = std::time::SystemTime::now()
402            .duration_since(std::time::UNIX_EPOCH)
403            .map(|d| d.as_nanos())
404            .unwrap_or(0)
405            ^ (std::process::id() as u128);
406        bytes[..16].copy_from_slice(&seed.to_le_bytes());
407    }
408    let mut s = String::with_capacity(32);
409    use std::fmt::Write as _;
410    for b in bytes {
411        let _ = write!(s, "{b:02x}");
412    }
413    s
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn args(source: &str, bin: &str, to: &str) -> PublishArgs {
421        PublishArgs {
422            source: Some(source.to_string()),
423            bin: Some(bin.to_string()),
424            to: Some(to.to_string()),
425            cwd: None,
426            build: None,
427            no_build: true,
428            identity: None,
429            json: false,
430            gate: Vec::new(),
431        }
432    }
433
434    /// A tree with a manifest, and the manifest is the commit point: it
435    /// appears only at the end, and a failing gate leaves none behind.
436    #[test]
437    fn publishing_lands_a_tree_and_its_manifest() {
438        if !crate::util::system::has_command("rsync") {
439            return;
440        }
441        let base = std::env::temp_dir().join(format!("fdl-publish-{}", std::process::id()));
442        let (src, served) = (base.join("src"), base.join("served"));
443        std::fs::create_dir_all(&src).unwrap();
444        std::fs::write(src.join("main.rs"), "// code").unwrap();
445
446        let mut cli = args(
447            &format!("file://{}", src.display()),
448            "out",
449            &served.display().to_string(),
450        );
451        let tail = vec!["--model".to_string(), "olmo".to_string()];
452        publish(&cli, Some(&tail)).unwrap();
453
454        let tree = served.join(TREE_SUBDIR);
455        assert!(tree.join("main.rs").is_file(), "the tree was not published");
456        let manifest = Manifest::read(&tree).unwrap().expect("a manifest");
457        assert_eq!(manifest.bin, "out");
458        assert_eq!(manifest.args, tail);
459        assert!(
460            !manifest.built,
461            "--no-build must be recorded, not glossed over"
462        );
463        assert_eq!(
464            manifest.origin.as_deref(),
465            Some(&*format!("file://{}", src.display()))
466        );
467
468        // Now a real gate that fails: the manifest must be GONE, because a
469        // worker reading one would be told a broken tree is ready.
470        cli.no_build = false;
471        cli.build = Some("exit 7".to_string());
472        let err = publish(&cli, None).unwrap_err();
473        assert!(
474            err.message().contains("Nothing was published"),
475            "got: {err:?}"
476        );
477        assert_eq!(
478            Manifest::read(&tree).unwrap(),
479            None,
480            "a failed gate left a manifest"
481        );
482
483        // And a gate that passes writes it again, with the build recorded.
484        cli.build = Some("printf x > out".to_string());
485        publish(&cli, None).unwrap();
486        let manifest = Manifest::read(&tree).unwrap().expect("a manifest");
487        assert!(manifest.built);
488        assert!(
489            manifest.args.is_empty(),
490            "no tail means no args, not the previous ones"
491        );
492
493        // Every publish is a NEW run identity — chaining "same args, new
494        // code" is the common re-publish, which any content hash would
495        // call identical. The nonce is what lets the join window refuse
496        // a cohort straddling this boundary.
497        let first_run = manifest.run.clone().expect("a publish stamps a run id");
498        publish(&cli, None).unwrap();
499        let manifest = Manifest::read(&tree).unwrap().expect("a manifest");
500        assert_ne!(
501            manifest.run,
502            Some(first_run),
503            "a re-publish must mint a fresh id"
504        );
505        let _ = std::fs::remove_dir_all(&base);
506    }
507
508    /// A spec that does not parse must not cost the fleet its manifest:
509    /// clearing it takes every box out of service until a build passes, so
510    /// a typo would idle the fleet over something that never reached the
511    /// tree.
512    #[test]
513    fn a_bad_spec_leaves_the_published_run_alone() {
514        let base = std::env::temp_dir().join(format!("fdl-publish-typo-{}", std::process::id()));
515        let tree = base.join(TREE_SUBDIR);
516        std::fs::create_dir_all(&tree).unwrap();
517        let live = Manifest {
518            bin: "target/release/train".into(),
519            built: true,
520            ..Default::default()
521        };
522        live.write(&tree).unwrap();
523
524        let cli = args(
525            "nonsense-with-no-scheme",
526            "out",
527            &base.display().to_string(),
528        );
529        assert!(publish(&cli, None).is_err());
530        assert_eq!(
531            Manifest::read(&tree).unwrap(),
532            Some(live),
533            "the live run was cleared"
534        );
535        let _ = std::fs::remove_dir_all(&base);
536    }
537
538    /// The report's pairing, proven against the real tool: behind
539    /// `command="rrsync -ro <served>"` a worker's spec path is `/tree`,
540    /// and the absolute spelling double-roots under the served dir and
541    /// fails. This is a COMPOSITION failure — each printed line was
542    /// individually right while the pair was unfollowable — so the
543    /// guard runs the composed recipe, not the pieces. Skipped where
544    /// rrsync is absent (it ships in the rsync package).
545    #[test]
546    #[cfg(unix)]
547    fn the_rrsync_pairing_serves_tree_and_refuses_the_absolute_path() {
548        use std::os::unix::fs::PermissionsExt;
549        if !crate::util::system::has_command("rsync") {
550            return;
551        }
552        // Presence is not runnability: Ubuntu's rrsync is a python3
553        // script, so on a box holding rrsync but not python3 the spawn
554        // "succeeds" and /usr/bin/env exits 127. Probe by running it —
555        // argument errors prove the interpreter is there.
556        match Command::new("rrsync").output() {
557            Err(_) => return,
558            Ok(o) if o.status.code() == Some(127) => return,
559            Ok(_) => {}
560        }
561        let base = std::env::temp_dir().join(format!("fdl-publish-rr-{}", std::process::id()));
562        let served = base.join("served");
563        let src = base.join("src");
564        std::fs::create_dir_all(&src).unwrap();
565        std::fs::write(src.join("main.rs"), "// code").unwrap();
566        let cli = args(
567            &format!("file://{}", src.display()),
568            "out",
569            &served.display().to_string(),
570        );
571        publish(&cli, None).unwrap();
572        let tree = served.join(TREE_SUBDIR);
573
574        // An ssh stand-in that hands the client's command to rrsync the
575        // way a forced authorized_keys command would.
576        let rsh = base.join("rsh.sh");
577        std::fs::write(
578            &rsh,
579            format!(
580                "#!/bin/sh\nshift\nSSH_ORIGINAL_COMMAND=\"$*\" exec rrsync -ro {}\n",
581                served.display(),
582            ),
583        )
584        .unwrap();
585        std::fs::set_permissions(&rsh, std::fs::Permissions::from_mode(0o755)).unwrap();
586        // `sh <script>` rather than the script alone: rsync EXECS what
587        // `-e` names, and exec'ing a file this multithreaded test binary
588        // has just written races any other test's fork (the inherited
589        // write fd makes it ETXTBSY). Reading it as sh's argument cannot.
590        let rsh_cmd = format!("sh {}", rsh.display());
591        let fetch = |path: &str, dest: &str| {
592            Command::new("rsync")
593                .args([
594                    "-a",
595                    "-e",
596                    &rsh_cmd,
597                    &format!("fake:{path}/"),
598                    &base.join(dest).display().to_string(),
599                ])
600                .output()
601                .unwrap()
602        };
603
604        let ok = fetch(&format!("/{TREE_SUBDIR}"), "out-tree");
605        assert!(
606            ok.status.success(),
607            "{}",
608            String::from_utf8_lossy(&ok.stderr)
609        );
610        assert!(
611            base.join("out-tree").join(source::MANIFEST_FILE).is_file(),
612            "the /tree spelling must deliver the manifest with the source",
613        );
614
615        let refused = fetch(&tree.display().to_string(), "out-abs");
616        assert!(
617            !refused.status.success(),
618            "the absolute path must NOT resolve behind rrsync — if this \
619             starts passing, the report's pairing text is stale",
620        );
621        let _ = std::fs::remove_dir_all(&base);
622    }
623
624    /// The flags-over-block contract, on a literal block (no file IO —
625    /// the loader is `fdl join`'s own walk, already covered there).
626    #[test]
627    fn the_publish_block_fills_gaps_and_flags_win() {
628        let block = crate::config::PublishBlock {
629            source: Some("file:///srv/train".into()),
630            bin: Some("target/release/train".into()),
631            cwd: Some("member".into()),
632            build: Some("./ci/build.sh".into()),
633            to: Some("/srv/run".into()),
634            identity: Some("/etc/flodl/pub_key".into()),
635            args: vec!["--model".into(), "olmo".into()],
636        };
637
638        // Bare `fdl publish`: the block carries everything.
639        let bare = PublishArgs {
640            source: None,
641            bin: None,
642            to: None,
643            cwd: None,
644            build: None,
645            no_build: false,
646            identity: None,
647            json: false,
648            gate: Vec::new(),
649        };
650        let (merged, tail) = with_block_defaults(&bare, None, Some(block.clone()));
651        assert_eq!(merged.source.as_deref(), Some("file:///srv/train"));
652        assert_eq!(merged.bin.as_deref(), Some("target/release/train"));
653        assert_eq!(merged.cwd.as_deref(), Some("member"));
654        assert_eq!(merged.build.as_deref(), Some("./ci/build.sh"));
655        assert_eq!(merged.to.as_deref(), Some("/srv/run"));
656        assert_eq!(merged.identity.as_deref(), Some("/etc/flodl/pub_key"));
657        assert_eq!(
658            tail.as_deref(),
659            Some(&["--model".to_string(), "olmo".to_string()][..])
660        );
661
662        // Flags win field by field, and an EMPTY `--` tail replaces the
663        // block's args — explicitly none is a sayable answer.
664        let flags = PublishArgs {
665            source: Some("rsync://exa:/home/op/tree".into()),
666            bin: None,
667            to: None,
668            cwd: None,
669            build: None,
670            no_build: false,
671            identity: None,
672            json: false,
673            gate: Vec::new(),
674        };
675        let empty: Vec<String> = Vec::new();
676        let (merged, tail) = with_block_defaults(&flags, Some(&empty), Some(block));
677        assert_eq!(merged.source.as_deref(), Some("rsync://exa:/home/op/tree"));
678        assert_eq!(merged.bin.as_deref(), Some("target/release/train"));
679        assert_eq!(
680            tail.as_deref(),
681            Some(&[][..]),
682            "an empty tail must replace, not defer"
683        );
684
685        // No block at all: flags and tail pass through untouched.
686        let (merged, tail) = with_block_defaults(&bare, None, None);
687        assert_eq!(merged.source, None);
688        assert_eq!(tail, None);
689    }
690
691    /// The report's two renderings draw from one value; this pins the
692    /// machine twin's shape (the dashboard contract).
693    #[test]
694    fn the_json_report_carries_both_worker_spellings() {
695        let manifest = Manifest {
696            bin: "target/release/train".into(),
697            args: vec!["--model".into(), "olmo".into()],
698            run: Some("a1b2c3d4e5f60718".into()),
699            rustc: Some("rustc 1.90.0".into()),
700            built: true,
701            ..Default::default()
702        };
703        let v = report_value(Path::new("/srv/run"), Path::new("/srv/run/tree"), &manifest);
704        assert_eq!(v["tree"], "/srv/run/tree");
705        assert_eq!(v["served"], "/srv/run");
706        assert_eq!(v["built"], true);
707        assert_eq!(v["run"], "a1b2c3d4e5f60718");
708        assert_eq!(v["args"], serde_json::json!(["--model", "olmo"]));
709        let host = crate::cluster::resolve_local_hostname();
710        assert_eq!(
711            v["worker_specs"]["plain"],
712            format!("rsync://{host}:/srv/run/tree")
713        );
714        assert_eq!(v["worker_specs"]["rrsync"], format!("rsync://{host}:/tree"));
715    }
716
717    /// `--gate <variant>`: a missing variant names the fetch, a present
718    /// one runs the recipe with that variant's env (the rocm feature
719    /// derivation and the per-variant CARGO_TARGET_DIR are what the
720    /// probe recipe asserts), and a recipe failure publishes nothing.
721    #[test]
722    fn a_gate_variant_check_builds_with_that_variants_env() {
723        let base = std::env::temp_dir().join(format!("fdl-publish-gate-{}", std::process::id()));
724        let (root, tree) = (base.join("root"), base.join("tree"));
725        std::fs::create_dir_all(&tree).unwrap();
726        let mut cli = args("file:///unused", "out", "/unused");
727
728        // Absent variant: permanent, names the fetch.
729        let err =
730            check_gate_variant(&root, &tree, &cli, "precompiled/rocm7.0", &mut vec![]).unwrap_err();
731        assert!(
732            err.message().contains("fdl libtorch download"),
733            "got: {err:?}"
734        );
735
736        // Present variant: the recipe sees the rocm feature and its own
737        // target dir, and success needs no artifact anywhere.
738        std::fs::create_dir_all(root.join("libtorch/precompiled/rocm7.0/lib")).unwrap();
739        cli.build = Some(
740            "test \"$FDL_GPU_FEATURE\" = rocm && \
741             test \"$CARGO_TARGET_DIR\" = target/gate/precompiled-rocm7.0"
742                .to_string(),
743        );
744        check_gate_variant(&root, &tree, &cli, "precompiled/rocm7.0", &mut vec![])
745            .expect("the env probe recipe must pass");
746
747        // A failing check-build reports as "nothing was published".
748        cli.build = Some("exit 3".to_string());
749        let err =
750            check_gate_variant(&root, &tree, &cli, "precompiled/rocm7.0", &mut vec![]).unwrap_err();
751        assert!(
752            err.message().contains("Nothing was published"),
753            "got: {err:?}"
754        );
755        let _ = std::fs::remove_dir_all(&base);
756    }
757
758    #[test]
759    fn a_missing_source_or_bin_says_which() {
760        let mut cli = args("file:///nowhere", "out", "/tmp/fdl-publish-none");
761        cli.source = None;
762        assert!(
763            publish(&cli, None)
764                .unwrap_err()
765                .message()
766                .contains("needs a source")
767        );
768        let mut cli = args("file:///nowhere", "out", "/tmp/fdl-publish-none");
769        cli.bin = None;
770        assert!(publish(&cli, None).unwrap_err().message().contains("--bin"));
771    }
772
773    #[test]
774    fn the_manifest_round_trips_through_yaml() {
775        let dir = std::env::temp_dir().join(format!("fdl-publish-m-{}", std::process::id()));
776        std::fs::create_dir_all(&dir).unwrap();
777        let manifest = Manifest {
778            cwd: Some("ddp-bench".into()),
779            build: Some("cargo build --release".into()),
780            bin: "target/release/ddp-bench".into(),
781            args: vec!["--epochs".into(), "3".into()],
782            origin: Some("git+https://example.com/o/r#v1".into()),
783            rustc: Some("rustc 1.90.0".into()),
784            published_epoch: Some(1_780_000_000),
785            run: Some("a1b2c3d4e5f60718".into()),
786            built: true,
787        };
788        manifest.write(&dir).unwrap();
789        assert_eq!(Manifest::read(&dir).unwrap(), Some(manifest));
790        // Absent is a state with meaning, not an error.
791        Manifest::remove(&dir).unwrap();
792        assert_eq!(Manifest::read(&dir).unwrap(), None);
793        let _ = std::fs::remove_dir_all(&dir);
794    }
795}