1use 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
32const SERVED_SUBDIR: &str = "run";
36const TREE_SUBDIR: &str = "tree";
37
38pub 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
75fn 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
94fn 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 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 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", ¬es);
206 let manifest = result?;
207 manifest.write(&tree)?;
208 Ok((served, tree, manifest))
209}
210
211fn 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 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
250fn 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
294fn 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
318fn 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
374fn 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
392fn 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 #[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 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 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 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 #[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 #[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 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 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 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 #[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 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 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 let (merged, tail) = with_block_defaults(&bare, None, None);
687 assert_eq!(merged.source, None);
688 assert_eq!(tail, None);
689 }
690
691 #[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 #[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 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 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 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 Manifest::remove(&dir).unwrap();
792 assert_eq!(Manifest::read(&dir).unwrap(), None);
793 let _ = std::fs::remove_dir_all(&dir);
794 }
795}