1use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use crate::config::{DEFAULT_DATA_PATH, SshConfig};
26use crate::context::Context;
27use crate::source::{Built, Manifest};
28use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
29use crate::style;
30
31#[derive(Debug, PartialEq, Eq)]
33pub enum Fail {
34 Permanent(String),
38 Transient(String),
41}
42
43impl Fail {
44 pub fn message(&self) -> &str {
46 match self {
47 Fail::Permanent(m) | Fail::Transient(m) => m,
48 }
49 }
50
51 pub fn is_permanent(&self) -> bool {
53 matches!(self, Fail::Permanent(_))
54 }
55}
56
57#[derive(Debug, Default, PartialEq, Eq)]
59pub struct Prepared {
60 pub data_path: Option<PathBuf>,
65 pub libtorch: Option<(PathBuf, String)>,
71 pub bin: Option<Built>,
75 pub args: Option<Vec<String>>,
79 pub run_id: Option<String>,
83}
84
85#[derive(Debug, Default)]
87pub struct PrepareSpec<'a> {
88 pub data: DataSpec<'a>,
89 pub libtorch: Option<&'a str>,
93 pub active_libtorch: Option<&'a (PathBuf, String)>,
96 pub source: Option<SourceSpec<'a>>,
99 pub devices: Option<&'a [u8]>,
103}
104
105#[derive(Debug, Default)]
107pub struct SourceSpec<'a> {
108 pub from: &'a str,
110 pub cwd: Option<&'a str>,
113 pub build: Option<&'a str>,
115 pub bin: Option<&'a str>,
118 pub ssh: Option<&'a SshConfig>,
121}
122
123#[derive(Debug, Default)]
126pub struct DataSpec<'a> {
127 pub path: Option<&'a str>,
129 pub source: Option<&'a str>,
131 pub ssh: Option<&'a SshConfig>,
147}
148
149const CACHE_SUBPATH: &str = ".flodl/data";
155
156const LOW_SPACE_KIB: u64 = 1 << 20;
160
161const SOURCE_SUBDIR: &str = "source";
166
167pub fn prepare(spec: &PrepareSpec, notes: &mut Vec<String>) -> Result<Prepared, Fail> {
178 check_gpu_stack()?;
179 let data_path = resolve_data_root(&spec.data, notes)?;
180 check_local_dirs(notes)?;
181
182 let fetched = match &spec.source {
183 Some(source) => Some(fetch_source(source, notes)?),
184 None => None,
185 };
186 let libtorch = match spec.libtorch {
187 Some(token) => Some(acquire_libtorch(token, notes)?),
188 None => spec.active_libtorch.cloned(),
189 };
190 if let Some(lt) = &libtorch {
191 check_arch_coverage(lt, spec.devices)?;
192 }
193 let (bin, args, run_id) = match (&spec.source, &fetched) {
194 (Some(source), Some((tree, manifest))) => {
195 let recipe = merge_manifest(source, manifest.as_ref())?;
196 let built = build_source(&recipe, tree, libtorch.as_ref(), notes)?;
197 (
198 Some(built),
199 manifest.as_ref().map(|m| m.args.clone()),
200 manifest.as_ref().and_then(|m| m.run.clone()),
201 )
202 }
203 _ => (None, None, None),
204 };
205 Ok(Prepared {
206 data_path,
207 libtorch,
208 bin,
209 args,
210 run_id,
211 })
212}
213
214fn merge_manifest<'a>(
222 local: &'a SourceSpec<'a>,
223 manifest: Option<&'a Manifest>,
224) -> Result<Recipe<'a>, Fail> {
225 let Some(m) = manifest else {
226 let Some(bin) = local.bin else {
227 return Err(Fail::Transient(
233 "the fetched source carries no run manifest and this box \
234 declares no artifact — publish a run on the controller \
235 (`fdl publish`), or name it locally with `--source-bin`"
236 .to_string(),
237 ));
238 };
239 return Ok(Recipe {
240 cwd: local.cwd,
241 build: local.build,
242 bin,
243 });
244 };
245 Ok(Recipe {
246 cwd: m.cwd.as_deref().or(local.cwd),
247 build: m.build.as_deref().or(local.build),
248 bin: &m.bin,
249 })
250}
251
252#[derive(Debug)]
255struct Recipe<'a> {
256 cwd: Option<&'a str>,
257 build: Option<&'a str>,
258 bin: &'a str,
259}
260
261fn acquire_libtorch(token: &str, notes: &mut Vec<String>) -> Result<(PathBuf, String), Fail> {
276 let variant = parse_libtorch_token(token)?;
277 let ctx = Context::global();
278 let id = crate::libtorch::download::run_with_context(
279 crate::libtorch::download::DownloadOpts {
280 variant,
281 custom_path: None,
282 activate: true,
285 dry_run: false,
286 force_linux: false,
287 },
288 &ctx,
289 )
290 .map_err(Fail::Transient)?;
293
294 let dir = ctx.root.join("libtorch").join(&id);
295 if !dir.join("lib").is_dir() {
296 return Err(Fail::Permanent(format!(
297 "libtorch `{id}` is not usable at {} (no lib/) — remove it and \
298 let fdl fetch it again",
299 dir.display(),
300 )));
301 }
302 notes.push(format!("libtorch: {id} at {}", dir.display()));
303 Ok((dir, id))
304}
305
306fn parse_libtorch_token(token: &str) -> Result<crate::libtorch::download::Variant, Fail> {
310 use crate::libtorch::download::Variant;
311 match token.trim() {
312 "auto" => Ok(Variant::Auto),
313 "cpu" => Ok(Variant::Cpu),
314 "cu126" | "12.6" => Ok(Variant::Cuda126),
315 "cu128" | "12.8" => Ok(Variant::Cuda128),
316 "rocm7.0" | "rocm70" | "7.0" => Ok(Variant::Rocm70),
317 "rocm7.1" | "rocm71" | "7.1" => Ok(Variant::Rocm71),
318 other => Err(Fail::Permanent(format!(
319 "unknown libtorch variant `{other}` — fdl ships `auto`, `cpu`, \
320 `cu126`, `cu128`, `rocm7.0` and `rocm7.1`. `auto` picks from the \
321 devices this box has, which is what lets one image serve both \
322 vendors"
323 ))),
324 }
325}
326
327fn fetch_source(
334 spec: &SourceSpec,
335 notes: &mut Vec<String>,
336) -> Result<(PathBuf, Option<Manifest>), Fail> {
337 let source = crate::source::parse(spec.from)?;
338 let dest = Context::global().root.join(SOURCE_SUBDIR);
339 crate::source::materialize(&source, &dest, spec.ssh, notes)?;
340 let manifest = Manifest::read(&dest)?;
341 if let Some(m) = &manifest {
342 notes.push(format!(
343 "run manifest: {}bin {}{}{}{}",
344 m.run
345 .as_deref()
346 .map(|r| format!("run {}… ", &r[..r.len().min(8)]))
347 .unwrap_or_default(),
348 m.bin,
349 m.cwd
350 .as_deref()
351 .map(|c| format!(" in {c}"))
352 .unwrap_or_default(),
353 m.published_epoch
354 .and_then(age_hint)
355 .map(|age| format!(", published {age}"))
356 .unwrap_or_default(),
357 if m.built {
358 ""
359 } else {
360 " — NOT built by the controller"
361 },
362 ));
363 if let (Some(theirs), Some(ours)) = (&m.rustc, local_rustc())
364 && theirs != &ours
365 {
366 notes.push(format!(
367 "the controller built this with {theirs}, this box has \
368 {ours} — advisory only, every box compiles its own \
369 binary and a toolchain too old fails loudly at compile \
370 time",
371 ));
372 }
373 }
374 Ok((dest, manifest))
375}
376
377fn age_hint(then: u64) -> Option<String> {
381 let now = std::time::SystemTime::now()
382 .duration_since(std::time::UNIX_EPOCH)
383 .ok()?
384 .as_secs();
385 let secs = now.checked_sub(then)?;
386 Some(match secs {
387 0..=90 => "just now".to_string(),
388 s if s < 5400 => format!("{}m ago", s / 60),
389 s if s < 172_800 => format!("{}h ago", s / 3600),
390 s => format!("{}d ago", s / 86_400),
391 })
392}
393
394fn local_rustc() -> Option<String> {
396 let out = Command::new("rustc").arg("-V").output().ok()?;
397 out.status
398 .success()
399 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
400 .filter(|v| !v.is_empty())
401}
402
403fn build_source(
405 recipe: &Recipe,
406 tree: &Path,
407 libtorch: Option<&(PathBuf, String)>,
408 notes: &mut Vec<String>,
409) -> Result<Built, Fail> {
410 if libtorch.is_none() {
411 notes.push(
412 "no libtorch is active on this box and none was requested, so \
413 the build gets no LIBTORCH_PATH — set `libtorch:` (`auto` \
414 picks for this box) unless the recipe supplies its own"
415 .to_string(),
416 );
417 }
418 let env = crate::source::build_env(libtorch);
419 crate::source::build(tree, recipe.cwd, recipe.build, recipe.bin, &env, notes).map_err(|e| {
420 if e.is_permanent() {
421 return e;
422 }
423 if let Some((_, variant)) = libtorch
434 && let flodl_hw::VariantClass::Vendor(vendor) =
435 flodl_hw::classify_variant_label(variant)
436 && let Some(gap) = crate::util::requirements::toolkit_gap(vendor)
437 {
438 return Fail::Permanent(format!(
439 "{} — and this box is missing the {vendor} toolkit \
440 headers under {} ({}), which a `--features {}` \
441 compile needs. Re-dialing cannot install a package: \
442 {}",
443 e.message(),
444 gap.root.display(),
445 gap.headers.join(", "),
446 vendor.cargo_feature(),
447 gap.install,
448 ));
449 }
450 Fail::Transient(format!(
453 "{} — fix it at the source; this box picks the fix up on its \
454 next dial",
455 e.message(),
456 ))
457 })
458}
459
460fn check_gpu_stack() -> Result<(), Fail> {
487 flodl_hw::survey_visible()
488 .require_devices()
489 .map(|_| ())
490 .map_err(|why| {
491 Fail::Permanent(format!(
492 "{why} This box has no rank to offer; `fdl probe` has the \
493 full picture. (A driver still coming up at boot belongs \
494 before `fdl join`, not inside its re-dial loop.)"
495 ))
496 })
497}
498
499fn check_arch_coverage(libtorch: &(PathBuf, String), offered: Option<&[u8]>) -> Result<(), Fail> {
518 let (dir, label) = libtorch;
519 let flodl_hw::VariantClass::Vendor(vendor) = flodl_hw::classify_variant_label(label) else {
520 return Ok(());
521 };
522 let info = crate::libtorch::detect::libtorch_info_from_dir(label.clone(), dir);
523 let Some(archs) = info.archs.clone() else {
524 return Ok(());
525 };
526 let devices: Vec<_> = flodl_hw::survey_visible()
527 .devices
528 .into_iter()
529 .filter(|d| d.vendor == vendor)
530 .filter(|d| offered.is_none_or(|ids| ids.contains(&d.index)))
531 .collect();
532 if devices.is_empty() {
533 return Ok(());
536 }
537 let mut details = Vec::new();
538 let coverage = crate::libtorch::detect::arch_coverage(&info, &devices, &mut details);
539 if coverage.iter().all(|(_, ok)| *ok) {
540 return Ok(());
541 }
542 Err(Fail::Permanent(format!(
543 "libtorch `{label}` (archs `{archs}`) ships no kernel for part of \
544 what this box offers: {} The first GPU op would die with `no \
545 kernel image is available` — after admission counted this host \
546 into a quorum. `libtorch: auto` picks a covering variant when \
547 one exists; `--devices` can scope the offer to covered cards",
548 details.join(" "),
549 )))
550}
551
552fn resolve_data_root(spec: &DataSpec, notes: &mut Vec<String>) -> Result<Option<PathBuf>, Fail> {
564 let Some(source) = spec.source else {
565 let Some(path) = spec.path else {
566 return Ok(None);
567 };
568 let path = absolute(path)?;
569 verify_source_root(&path)?;
570 return Ok(Some(path));
571 };
572
573 let mountpoint = absolute(spec.path.unwrap_or(DEFAULT_DATA_PATH))?;
574 let target = parse_source(source)?;
575 ensure_mountpoint(&mountpoint)?;
576
577 match crate::probe::mounted_at(&mountpoint) {
578 Some((mounted_source, fs_type)) => {
579 if mounted_source != target.remote {
580 notes.push(format!(
581 "{} already carries a mount from `{mounted_source}` \
582 ({fs_type}), not the configured `{}` — leaving it \
583 alone; the ranks will read whatever is mounted \
584 there. Unmount it (`fusermount -u {}`) to let fdl \
585 mount the configured source.",
586 mountpoint.display(),
587 target.remote,
588 mountpoint.display(),
589 ));
590 } else {
591 notes.push(format!(
592 "source root {} already mounted from `{mounted_source}` \
593 ({fs_type})",
594 mountpoint.display(),
595 ));
596 }
597 }
598 None => {
599 mount_sshfs(&target, &mountpoint, spec.ssh)?;
600 notes.push(format!(
601 "mounted `{}` read-only at {}",
602 target.remote,
603 mountpoint.display(),
604 ));
605 }
606 }
607 verify_source_root(&mountpoint)?;
608 Ok(Some(mountpoint))
609}
610
611fn absolute(path: &str) -> Result<PathBuf, Fail> {
617 std::path::absolute(path)
618 .map_err(|e| Fail::Permanent(format!("cannot resolve data path `{path}`: {e}")))
619}
620
621fn verify_source_root(path: &Path) -> Result<(), Fail> {
625 if !path.is_dir() {
626 return Err(Fail::Permanent(format!(
627 "dataset source root {} is not a readable directory — \
628 provision it (mount or create it), point `data_path:` \
629 somewhere that exists, or set `data_source:` so fdl mounts \
630 it here",
631 path.display(),
632 )));
633 }
634 std::fs::read_dir(path).map_err(|e| {
635 Fail::Permanent(format!(
636 "dataset source root {} cannot be listed: {e}",
637 path.display(),
638 ))
639 })?;
640 Ok(())
641}
642
643fn ensure_mountpoint(dir: &Path) -> Result<(), Fail> {
648 if dir.is_dir() {
649 return Ok(());
650 }
651 std::fs::create_dir_all(dir).map_err(|e| {
652 Fail::Permanent(format!(
653 "mountpoint {} does not exist and cannot be created: {e} — \
654 create it once during provisioning (`sudo mkdir -p {} && \
655 sudo chown $USER {}`), or set `data_path:` to a directory \
656 this user owns",
657 dir.display(),
658 dir.display(),
659 dir.display(),
660 ))
661 })
662}
663
664fn parse_source(spec: &str) -> Result<SshTarget, Fail> {
672 match split_scheme(spec) {
673 (Some("sshfs"), rest) => parse_ssh_target(rest).map_err(|why| {
674 Fail::Permanent(format!(
675 "invalid data_source `sshfs://{rest}` — {why}. Expected \
676 `sshfs://[user@]host[:port]/abs/path` (or the scp spelling \
677 `sshfs://[user@]host:/abs/path`)"
678 ))
679 }),
680 (Some(scheme), _) => Err(Fail::Permanent(format!(
681 "unsupported data_source scheme `{scheme}://` — fdl ships \
682 `sshfs://` today. A source another tool already mounted \
683 needs no scheme: name its path in `data_path:` instead"
684 ))),
685 (None, _) => Err(Fail::Permanent(format!(
686 "data_source `{spec}` names no transport — a source that is \
687 already mounted goes in `data_path:`; a source fdl should \
688 mount needs a scheme, e.g. \
689 `sshfs://user@host:/flodl/data`"
690 ))),
691 }
692}
693
694fn mount_sshfs(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Result<(), Fail> {
709 if !crate::util::system::has_command("sshfs") {
710 return Err(Fail::Permanent(format!(
711 "data_source needs sshfs, which is not installed — \
712 `sudo apt install sshfs` (or mount `{}` during provisioning \
713 and declare a bare `data_path:`)",
714 target.remote,
715 )));
716 }
717 let argv = sshfs_argv(target, mountpoint, ssh);
718 let out = Command::new(&argv[0])
719 .args(&argv[1..])
720 .output()
721 .map_err(|e| Fail::Permanent(format!("spawn sshfs: {e}")))?;
722 if !out.status.success() {
723 let stderr = String::from_utf8_lossy(&out.stderr);
724 return Err(Fail::Transient(format!(
725 "mounting `{}` at {} failed ({}): {}",
726 target.remote,
727 mountpoint.display(),
728 out.status,
729 stderr.trim(),
730 )));
731 }
732 if crate::probe::mounted_at(mountpoint).is_none() {
736 return Err(Fail::Transient(format!(
737 "sshfs reported success but nothing is mounted at {} — the \
738 far side likely dropped the connection",
739 mountpoint.display(),
740 )));
741 }
742 Ok(())
743}
744
745fn sshfs_argv(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Vec<String> {
749 let mut argv: Vec<String> = vec![
750 "sshfs".into(),
751 target.remote.clone(),
752 mountpoint.display().to_string(),
753 ];
754 let mut opt = |v: String| {
755 argv.push("-o".into());
756 argv.push(v);
757 };
758 if let Some(ssh) = ssh {
759 if let Some(warning) =
762 crate::cluster::batchmode_override_warning(&ssh.options, &target.remote)
763 {
764 eprintln!("{warning}");
765 }
766 for o in &ssh.options {
767 opt(o.clone());
768 }
769 if let Some(id) = &ssh.identity_file {
770 opt(format!("IdentityFile={id}"));
771 }
772 }
773 if let Some(port) = target.port {
774 opt(format!("port={port}"));
775 }
776 for o in [
781 "ro",
782 "reconnect",
783 "ServerAliveInterval=15",
784 "ServerAliveCountMax=3",
785 "BatchMode=yes",
786 ] {
787 opt(o.to_string());
788 }
789 argv
790}
791
792fn check_local_dirs(notes: &mut Vec<String>) -> Result<(), Fail> {
803 match std::env::var_os("HOME") {
804 Some(home) => {
805 let cache = PathBuf::from(home).join(CACHE_SUBPATH);
806 check_writable("dataset cache", &cache, true, notes)?;
807 }
808 None => notes.push(
809 "HOME is unset, so flodl will cache datasets under the temp \
810 directory — on a tmpfs that spends RAM, not disk. Set HOME, \
811 or pre-provision the source root."
812 .to_string(),
813 ),
814 }
815 check_writable("disk stage", &std::env::temp_dir(), false, notes)
816}
817
818fn check_writable(
825 label: &str,
826 dir: &Path,
827 create: bool,
828 notes: &mut Vec<String>,
829) -> Result<(), Fail> {
830 if create {
831 std::fs::create_dir_all(dir).map_err(|e| {
832 Fail::Permanent(format!(
833 "{label} directory {} cannot be created: {e}",
834 dir.display(),
835 ))
836 })?;
837 } else if !dir.is_dir() {
838 return Err(Fail::Permanent(format!(
839 "{label} directory {} does not exist",
840 dir.display(),
841 )));
842 }
843 let probe = dir.join(format!(
844 ".fdl-prepare-{}-{}",
845 std::process::id(),
846 next_probe_id(),
847 ));
848 let written = std::fs::write(&probe, b"fdl prepare\n");
849 let _ = std::fs::remove_file(&probe);
850 written.map_err(|e| {
851 Fail::Permanent(format!(
852 "{label} directory {} is not writable: {e} — training stages \
853 data there, so it must be",
854 dir.display(),
855 ))
856 })?;
857
858 if let Some(fs_type) = crate::probe::detect_fs_type(dir)
859 && (fs_type == "tmpfs" || fs_type == "ramfs")
860 {
861 notes.push(format!(
862 "{label} directory {} is on {fs_type} (RAM-backed) — \
863 staging there spends RAM, not disk",
864 dir.display(),
865 ));
866 }
867 if let Some(kib) = available_kib(dir)
868 && kib < LOW_SPACE_KIB
869 {
870 notes.push(format!(
871 "{label} directory {} has {} MiB free — smaller than any \
872 real corpus",
873 dir.display(),
874 kib / 1024,
875 ));
876 }
877 Ok(())
878}
879
880fn available_kib(dir: &Path) -> Option<u64> {
885 let out = Command::new("df").arg("-Pk").arg(dir).output().ok()?;
886 if !out.status.success() {
887 return None;
888 }
889 let text = String::from_utf8_lossy(&out.stdout);
890 text.lines()
891 .nth(1)?
892 .split_whitespace()
893 .nth(3)?
894 .parse::<u64>()
895 .ok()
896}
897
898fn next_probe_id() -> u64 {
901 use std::sync::atomic::{AtomicU64, Ordering};
902 static NEXT: AtomicU64 = AtomicU64::new(0);
903 NEXT.fetch_add(1, Ordering::Relaxed)
904}
905
906pub fn print_notes(command: &str, notes: &[String]) {
910 for note in notes {
911 eprintln!("{}", style::dim(&format!("fdl {command}: {note}")));
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
927 fn the_gpu_gate_blocks_exactly_when_there_is_no_usable_device() {
928 let usable = !flodl_hw::survey_visible().devices.is_empty();
929 assert_eq!(
930 check_gpu_stack().is_ok(),
931 usable,
932 "the gate must follow the device list, not the findings",
933 );
934 }
935
936 #[test]
941 fn a_variant_covering_none_of_the_offered_cards_is_refused() {
942 let dir = std::env::temp_dir().join(format!(
943 "fdl-prep-arch-{}-{}",
944 std::process::id(),
945 next_probe_id(),
946 ));
947 std::fs::create_dir_all(&dir).unwrap();
948 std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
951 let lt = (dir.clone(), "precompiled/cu128".to_string());
952 let nvidia_present = flodl_hw::survey_visible()
953 .devices
954 .iter()
955 .any(|d| d.vendor == flodl_hw::GpuVendor::Nvidia);
956 match check_arch_coverage(<, None) {
957 Err(err) => {
958 assert!(nvidia_present, "refused with no matching device: {err:?}");
959 assert!(
960 err.is_permanent(),
961 "kernels do not grow by waiting: {err:?}"
962 );
963 assert!(err.message().contains("no kernel image"), "got: {err:?}");
964 }
965 Ok(()) => assert!(
966 !nvidia_present,
967 "an NVIDIA card offered against archs `0.0` must be refused",
968 ),
969 }
970 assert!(check_arch_coverage(&(dir.clone(), "precompiled/cpu".into()), None).is_ok());
973 std::fs::remove_file(dir.join(".arch")).unwrap();
974 assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), None).is_ok());
975 std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
978 assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), Some(&[])).is_ok());
979 let _ = std::fs::remove_dir_all(&dir);
980 }
981
982 #[test]
983 fn the_sshfs_scheme_reaches_the_shared_grammar() {
984 assert_eq!(
987 parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap(),
988 SshTarget {
989 remote: "flodl@exa:/flodl/data".into(),
990 port: Some(2222)
991 },
992 );
993 }
994
995 #[test]
996 fn a_published_manifest_outranks_the_boxs_own_recipe() {
997 let local = SourceSpec {
1001 from: "rsync://ctrl:/srv/run/tree",
1002 cwd: Some("stale"),
1003 build: Some("stale-build"),
1004 bin: Some("stale-bin"),
1005 ssh: None,
1006 };
1007 let manifest = Manifest {
1008 cwd: Some("ddp-bench".into()),
1009 build: Some("cargo build --release".into()),
1010 bin: "target/release/ddp-bench".into(),
1011 ..Manifest::default()
1012 };
1013 let recipe = merge_manifest(&local, Some(&manifest)).unwrap();
1014 assert_eq!(recipe.cwd, Some("ddp-bench"));
1015 assert_eq!(recipe.build, Some("cargo build --release"));
1016 assert_eq!(recipe.bin, "target/release/ddp-bench");
1017 }
1018
1019 #[test]
1020 fn a_manifest_that_says_nothing_leaves_the_local_answer_standing() {
1021 let local = SourceSpec {
1024 from: "file:///mnt/rdl",
1025 cwd: Some("ddp-bench"),
1026 build: Some("./ci/node-build.sh"),
1027 bin: Some("target/release/x"),
1028 ssh: None,
1029 };
1030 let bare = Manifest {
1031 bin: "target/release/y".into(),
1032 ..Manifest::default()
1033 };
1034 let recipe = merge_manifest(&local, Some(&bare)).unwrap();
1035 assert_eq!(recipe.cwd, Some("ddp-bench"));
1036 assert_eq!(recipe.build, Some("./ci/node-build.sh"));
1037 assert_eq!(recipe.bin, "target/release/y");
1038
1039 let recipe = merge_manifest(&local, None).unwrap();
1040 assert_eq!(recipe.bin, "target/release/x");
1041 }
1042
1043 #[test]
1044 fn no_manifest_and_no_local_artifact_waits_rather_than_stopping() {
1045 let local = SourceSpec {
1049 from: "rsync://ctrl:/srv/run/tree",
1050 ..Default::default()
1051 };
1052 let err = merge_manifest(&local, None).unwrap_err();
1053 assert!(!err.is_permanent(), "the fix is a publish away: {err:?}");
1054 assert!(err.message().contains("fdl publish"), "got: {err:?}");
1055 }
1056
1057 #[test]
1058 fn a_failed_build_is_classed_by_whether_the_toolkit_could_explain_it() {
1059 let dir = std::env::temp_dir().join(format!(
1060 "fdl-prep-toolkit-{}-{}",
1061 std::process::id(),
1062 next_probe_id(),
1063 ));
1064 std::fs::create_dir_all(&dir).unwrap();
1065 let fail = |variant: &str| {
1066 let libtorch = (dir.clone(), variant.to_string());
1067 build_source(
1068 &Recipe {
1069 cwd: None,
1070 build: Some("exit 3"),
1071 bin: "x",
1072 },
1073 &dir,
1074 Some(&libtorch),
1075 &mut Vec::new(),
1076 )
1077 .unwrap_err()
1078 };
1079 let err = fail("precompiled/rocm70");
1084 match crate::util::requirements::toolkit_gap(flodl_hw::GpuVendor::Amd) {
1085 Some(gap) => {
1086 assert!(
1087 err.is_permanent(),
1088 "waiting cannot install a package: {err:?}"
1089 );
1090 assert!(
1091 err.message().contains(&gap.install),
1092 "the fix must be named: {err:?}"
1093 );
1094 }
1095 None => assert!(
1096 !err.is_permanent(),
1097 "toolkit present, so a compile error stays a push away: {err:?}"
1098 ),
1099 }
1100 let err = fail("precompiled/cpu");
1103 assert!(!err.is_permanent(), "got: {err:?}");
1104 let _ = std::fs::remove_dir_all(&dir);
1105 }
1106
1107 #[test]
1108 fn a_source_spec_rejects_every_broken_shape_permanently() {
1109 for spec in [
1110 "/flodl/data", "smb://server/share", "sshfs://exa", "sshfs://exa:banana/data", "sshfs://:/flodl/data", "sshfs://exa:/", ] {
1117 let err = parse_source(spec).unwrap_err();
1118 assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
1119 assert!(err.message().contains("data_source"), "{spec}: {err:?}");
1122 }
1123 }
1124
1125 #[test]
1126 fn a_bare_path_names_the_field_it_belongs_in() {
1127 let err = parse_source("/flodl/data").unwrap_err();
1130 assert!(err.message().contains("data_path:"), "got: {err:?}");
1131 }
1132
1133 #[test]
1134 fn sshfs_argv_puts_user_options_before_the_defaults() {
1135 let ssh = SshConfig {
1136 target: Some("ctrl".into()),
1137 port: Some(2222),
1138 user: Some("join-user".into()),
1139 identity_file: Some("/etc/flodl/join_key".into()),
1140 options: vec!["ServerAliveInterval=5".into()],
1141 };
1142 let target = parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap();
1143 let argv = sshfs_argv(&target, Path::new("/flodl/data"), Some(&ssh));
1144 assert_eq!(argv[0], "sshfs");
1145 assert_eq!(argv[1], "flodl@exa:/flodl/data");
1146 assert_eq!(argv[2], "/flodl/data");
1147 let user_pos = argv
1150 .iter()
1151 .position(|a| a == "ServerAliveInterval=5")
1152 .unwrap();
1153 let default_pos = argv
1154 .iter()
1155 .position(|a| a == "ServerAliveInterval=15")
1156 .unwrap();
1157 assert!(user_pos < default_pos);
1158 assert!(argv.contains(&"IdentityFile=/etc/flodl/join_key".to_string()));
1159 assert!(argv.contains(&"port=2222".to_string()));
1162 assert!(argv.contains(&"BatchMode=yes".to_string()));
1163 assert!(argv.contains(&"ro".to_string()));
1165 }
1166
1167 #[test]
1168 fn sshfs_argv_without_an_ssh_block_still_carries_the_defaults() {
1169 let target = parse_source("sshfs://exa/data").unwrap();
1170 let argv = sshfs_argv(&target, Path::new("/mnt/d"), None);
1171 assert!(argv.contains(&"ro".to_string()));
1172 assert!(argv.contains(&"reconnect".to_string()));
1173 assert!(!argv.iter().any(|a| a.starts_with("IdentityFile")));
1174 assert!(!argv.iter().any(|a| a.starts_with("port=")));
1175 }
1176
1177 #[test]
1178 fn no_data_fields_prepares_nothing() {
1179 let mut notes = Vec::new();
1180 let got = resolve_data_root(&DataSpec::default(), &mut notes).unwrap();
1181 assert_eq!(
1182 got, None,
1183 "a run that never mentions data must ship nothing"
1184 );
1185 assert!(notes.is_empty());
1186 }
1187
1188 #[test]
1189 fn a_relative_declared_path_is_shipped_absolute() {
1190 let cwd = std::env::current_dir().unwrap();
1193 let name = format!("fdl-prep-rel-{}-{}", std::process::id(), next_probe_id());
1194 let dir = cwd.join(&name);
1195 std::fs::create_dir_all(&dir).unwrap();
1196 let spec = DataSpec {
1197 path: Some(&name),
1198 ..Default::default()
1199 };
1200 let got = resolve_data_root(&spec, &mut Vec::new()).unwrap();
1201 let _ = std::fs::remove_dir_all(&dir);
1202 assert_eq!(got, Some(dir));
1203 }
1204
1205 #[test]
1206 fn a_declared_path_is_verified_and_returned() {
1207 let dir = std::env::temp_dir().join(format!(
1208 "fdl-prep-src-{}-{}",
1209 std::process::id(),
1210 next_probe_id(),
1211 ));
1212 std::fs::create_dir_all(&dir).unwrap();
1213 let path = dir.display().to_string();
1214 let mut notes = Vec::new();
1215 let spec = DataSpec {
1216 path: Some(&path),
1217 ..Default::default()
1218 };
1219 assert_eq!(
1220 resolve_data_root(&spec, &mut notes).unwrap(),
1221 Some(dir.clone()),
1222 );
1223 let _ = std::fs::remove_dir_all(&dir);
1224 }
1225
1226 #[test]
1227 fn a_declared_path_that_is_not_there_is_permanent() {
1228 let missing = std::env::temp_dir()
1229 .join("fdl-prep-absent-do-not-create")
1230 .display()
1231 .to_string();
1232 let spec = DataSpec {
1233 path: Some(&missing),
1234 ..Default::default()
1235 };
1236 let err = resolve_data_root(&spec, &mut Vec::new()).unwrap_err();
1237 assert!(err.is_permanent(), "got: {err:?}");
1238 assert!(err.message().contains("data_source:"), "got: {err:?}");
1240 }
1241
1242 #[test]
1243 fn a_readable_source_root_needs_no_write_permission() {
1244 let dir = std::env::temp_dir().join(format!(
1249 "fdl-prep-ro-{}-{}",
1250 std::process::id(),
1251 next_probe_id(),
1252 ));
1253 std::fs::create_dir_all(&dir).unwrap();
1254 #[allow(unused_mut)]
1258 let mut perms = std::fs::metadata(&dir).unwrap().permissions();
1259 #[cfg(unix)]
1260 {
1261 use std::os::unix::fs::PermissionsExt;
1262 perms.set_mode(0o555);
1263 }
1264 std::fs::set_permissions(&dir, perms).unwrap();
1265 assert!(verify_source_root(&dir).is_ok());
1266 #[allow(unused_mut)]
1267 let mut perms = std::fs::metadata(&dir).unwrap().permissions();
1268 #[cfg(unix)]
1269 {
1270 use std::os::unix::fs::PermissionsExt;
1271 perms.set_mode(0o755);
1272 }
1273 std::fs::set_permissions(&dir, perms).unwrap();
1274 let _ = std::fs::remove_dir_all(&dir);
1275 }
1276
1277 #[test]
1278 fn a_writable_directory_passes_and_leaves_no_probe_file_behind() {
1279 let dir = std::env::temp_dir().join(format!(
1280 "fdl-prep-w-{}-{}",
1281 std::process::id(),
1282 next_probe_id(),
1283 ));
1284 let mut notes = Vec::new();
1285 check_writable("test", &dir, true, &mut notes).unwrap();
1286 let leftovers: Vec<_> = std::fs::read_dir(&dir)
1287 .unwrap()
1288 .map(|e| e.unwrap().file_name())
1289 .collect();
1290 assert!(
1291 leftovers.is_empty(),
1292 "probe file left behind: {leftovers:?}"
1293 );
1294 let _ = std::fs::remove_dir_all(&dir);
1295 }
1296
1297 #[test]
1298 fn a_missing_directory_we_must_not_create_is_permanent() {
1299 let dir = std::env::temp_dir().join("fdl-prep-absent-stage-dir");
1300 let err = check_writable("disk stage", &dir, false, &mut Vec::new()).unwrap_err();
1301 assert!(err.is_permanent(), "got: {err:?}");
1302 }
1303
1304 #[test]
1305 fn free_space_reads_back_for_a_directory_that_exists() {
1306 if !crate::util::system::has_command("df") {
1309 return;
1310 }
1311 let kib = available_kib(&std::env::temp_dir());
1312 assert!(kib.is_some_and(|k| k > 0), "got: {kib:?}");
1313 }
1314}