use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{DEFAULT_DATA_PATH, SshConfig};
use crate::context::Context;
use crate::source::{Built, Manifest};
use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
use crate::style;
#[derive(Debug, PartialEq, Eq)]
pub enum Fail {
Permanent(String),
Transient(String),
}
impl Fail {
pub fn message(&self) -> &str {
match self {
Fail::Permanent(m) | Fail::Transient(m) => m,
}
}
pub fn is_permanent(&self) -> bool {
matches!(self, Fail::Permanent(_))
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Prepared {
pub data_path: Option<PathBuf>,
pub libtorch: Option<(PathBuf, String)>,
pub bin: Option<Built>,
pub args: Option<Vec<String>>,
pub run_id: Option<String>,
}
#[derive(Debug, Default)]
pub struct PrepareSpec<'a> {
pub data: DataSpec<'a>,
pub libtorch: Option<&'a str>,
pub active_libtorch: Option<&'a (PathBuf, String)>,
pub source: Option<SourceSpec<'a>>,
pub devices: Option<&'a [u8]>,
}
#[derive(Debug, Default)]
pub struct SourceSpec<'a> {
pub from: &'a str,
pub cwd: Option<&'a str>,
pub build: Option<&'a str>,
pub bin: Option<&'a str>,
pub ssh: Option<&'a SshConfig>,
}
#[derive(Debug, Default)]
pub struct DataSpec<'a> {
pub path: Option<&'a str>,
pub source: Option<&'a str>,
pub ssh: Option<&'a SshConfig>,
}
const CACHE_SUBPATH: &str = ".flodl/data";
const LOW_SPACE_KIB: u64 = 1 << 20;
const SOURCE_SUBDIR: &str = "source";
pub fn prepare(spec: &PrepareSpec, notes: &mut Vec<String>) -> Result<Prepared, Fail> {
check_gpu_stack()?;
let data_path = resolve_data_root(&spec.data, notes)?;
check_local_dirs(notes)?;
let fetched = match &spec.source {
Some(source) => Some(fetch_source(source, notes)?),
None => None,
};
let libtorch = match spec.libtorch {
Some(token) => Some(acquire_libtorch(token, notes)?),
None => spec.active_libtorch.cloned(),
};
if let Some(lt) = &libtorch {
check_arch_coverage(lt, spec.devices)?;
}
let (bin, args, run_id) = match (&spec.source, &fetched) {
(Some(source), Some((tree, manifest))) => {
let recipe = merge_manifest(source, manifest.as_ref())?;
let built = build_source(&recipe, tree, libtorch.as_ref(), notes)?;
(
Some(built),
manifest.as_ref().map(|m| m.args.clone()),
manifest.as_ref().and_then(|m| m.run.clone()),
)
}
_ => (None, None, None),
};
Ok(Prepared {
data_path,
libtorch,
bin,
args,
run_id,
})
}
fn merge_manifest<'a>(
local: &'a SourceSpec<'a>,
manifest: Option<&'a Manifest>,
) -> Result<Recipe<'a>, Fail> {
let Some(m) = manifest else {
let Some(bin) = local.bin else {
return Err(Fail::Transient(
"the fetched source carries no run manifest and this box \
declares no artifact — publish a run on the controller \
(`fdl publish`), or name it locally with `--source-bin`"
.to_string(),
));
};
return Ok(Recipe {
cwd: local.cwd,
build: local.build,
bin,
});
};
Ok(Recipe {
cwd: m.cwd.as_deref().or(local.cwd),
build: m.build.as_deref().or(local.build),
bin: &m.bin,
})
}
#[derive(Debug)]
struct Recipe<'a> {
cwd: Option<&'a str>,
build: Option<&'a str>,
bin: &'a str,
}
fn acquire_libtorch(token: &str, notes: &mut Vec<String>) -> Result<(PathBuf, String), Fail> {
let variant = parse_libtorch_token(token)?;
let ctx = Context::global();
let id = crate::libtorch::download::run_with_context(
crate::libtorch::download::DownloadOpts {
variant,
custom_path: None,
activate: true,
dry_run: false,
force_linux: false,
},
&ctx,
)
.map_err(Fail::Transient)?;
let dir = ctx.root.join("libtorch").join(&id);
if !dir.join("lib").is_dir() {
return Err(Fail::Permanent(format!(
"libtorch `{id}` is not usable at {} (no lib/) — remove it and \
let fdl fetch it again",
dir.display(),
)));
}
notes.push(format!("libtorch: {id} at {}", dir.display()));
Ok((dir, id))
}
fn parse_libtorch_token(token: &str) -> Result<crate::libtorch::download::Variant, Fail> {
use crate::libtorch::download::Variant;
match token.trim() {
"auto" => Ok(Variant::Auto),
"cpu" => Ok(Variant::Cpu),
"cu126" | "12.6" => Ok(Variant::Cuda126),
"cu128" | "12.8" => Ok(Variant::Cuda128),
"rocm7.0" | "rocm70" | "7.0" => Ok(Variant::Rocm70),
"rocm7.1" | "rocm71" | "7.1" => Ok(Variant::Rocm71),
other => Err(Fail::Permanent(format!(
"unknown libtorch variant `{other}` — fdl ships `auto`, `cpu`, \
`cu126`, `cu128`, `rocm7.0` and `rocm7.1`. `auto` picks from the \
devices this box has, which is what lets one image serve both \
vendors"
))),
}
}
fn fetch_source(
spec: &SourceSpec,
notes: &mut Vec<String>,
) -> Result<(PathBuf, Option<Manifest>), Fail> {
let source = crate::source::parse(spec.from)?;
let dest = Context::global().root.join(SOURCE_SUBDIR);
crate::source::materialize(&source, &dest, spec.ssh, notes)?;
let manifest = Manifest::read(&dest)?;
if let Some(m) = &manifest {
notes.push(format!(
"run manifest: {}bin {}{}{}{}",
m.run
.as_deref()
.map(|r| format!("run {}… ", &r[..r.len().min(8)]))
.unwrap_or_default(),
m.bin,
m.cwd
.as_deref()
.map(|c| format!(" in {c}"))
.unwrap_or_default(),
m.published_epoch
.and_then(age_hint)
.map(|age| format!(", published {age}"))
.unwrap_or_default(),
if m.built {
""
} else {
" — NOT built by the controller"
},
));
if let (Some(theirs), Some(ours)) = (&m.rustc, local_rustc())
&& theirs != &ours
{
notes.push(format!(
"the controller built this with {theirs}, this box has \
{ours} — advisory only, every box compiles its own \
binary and a toolchain too old fails loudly at compile \
time",
));
}
}
Ok((dest, manifest))
}
fn age_hint(then: u64) -> Option<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let secs = now.checked_sub(then)?;
Some(match secs {
0..=90 => "just now".to_string(),
s if s < 5400 => format!("{}m ago", s / 60),
s if s < 172_800 => format!("{}h ago", s / 3600),
s => format!("{}d ago", s / 86_400),
})
}
fn local_rustc() -> Option<String> {
let out = Command::new("rustc").arg("-V").output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|v| !v.is_empty())
}
fn build_source(
recipe: &Recipe,
tree: &Path,
libtorch: Option<&(PathBuf, String)>,
notes: &mut Vec<String>,
) -> Result<Built, Fail> {
if libtorch.is_none() {
notes.push(
"no libtorch is active on this box and none was requested, so \
the build gets no LIBTORCH_PATH — set `libtorch:` (`auto` \
picks for this box) unless the recipe supplies its own"
.to_string(),
);
}
let env = crate::source::build_env(libtorch);
crate::source::build(tree, recipe.cwd, recipe.build, recipe.bin, &env, notes).map_err(|e| {
if e.is_permanent() {
return e;
}
if let Some((_, variant)) = libtorch
&& let flodl_hw::VariantClass::Vendor(vendor) =
flodl_hw::classify_variant_label(variant)
&& let Some(gap) = crate::util::requirements::toolkit_gap(vendor)
{
return Fail::Permanent(format!(
"{} — and this box is missing the {vendor} toolkit \
headers under {} ({}), which a `--features {}` \
compile needs. Re-dialing cannot install a package: \
{}",
e.message(),
gap.root.display(),
gap.headers.join(", "),
vendor.cargo_feature(),
gap.install,
));
}
Fail::Transient(format!(
"{} — fix it at the source; this box picks the fix up on its \
next dial",
e.message(),
))
})
}
fn check_gpu_stack() -> Result<(), Fail> {
flodl_hw::survey_visible()
.require_devices()
.map(|_| ())
.map_err(|why| {
Fail::Permanent(format!(
"{why} This box has no rank to offer; `fdl probe` has the \
full picture. (A driver still coming up at boot belongs \
before `fdl join`, not inside its re-dial loop.)"
))
})
}
fn check_arch_coverage(libtorch: &(PathBuf, String), offered: Option<&[u8]>) -> Result<(), Fail> {
let (dir, label) = libtorch;
let flodl_hw::VariantClass::Vendor(vendor) = flodl_hw::classify_variant_label(label) else {
return Ok(());
};
let info = crate::libtorch::detect::libtorch_info_from_dir(label.clone(), dir);
let Some(archs) = info.archs.clone() else {
return Ok(());
};
let devices: Vec<_> = flodl_hw::survey_visible()
.devices
.into_iter()
.filter(|d| d.vendor == vendor)
.filter(|d| offered.is_none_or(|ids| ids.contains(&d.index)))
.collect();
if devices.is_empty() {
return Ok(());
}
let mut details = Vec::new();
let coverage = crate::libtorch::detect::arch_coverage(&info, &devices, &mut details);
if coverage.iter().all(|(_, ok)| *ok) {
return Ok(());
}
Err(Fail::Permanent(format!(
"libtorch `{label}` (archs `{archs}`) ships no kernel for part of \
what this box offers: {} The first GPU op would die with `no \
kernel image is available` — after admission counted this host \
into a quorum. `libtorch: auto` picks a covering variant when \
one exists; `--devices` can scope the offer to covered cards",
details.join(" "),
)))
}
fn resolve_data_root(spec: &DataSpec, notes: &mut Vec<String>) -> Result<Option<PathBuf>, Fail> {
let Some(source) = spec.source else {
let Some(path) = spec.path else {
return Ok(None);
};
let path = absolute(path)?;
verify_source_root(&path)?;
return Ok(Some(path));
};
let mountpoint = absolute(spec.path.unwrap_or(DEFAULT_DATA_PATH))?;
let target = parse_source(source)?;
ensure_mountpoint(&mountpoint)?;
match crate::probe::mounted_at(&mountpoint) {
Some((mounted_source, fs_type)) => {
if mounted_source != target.remote {
notes.push(format!(
"{} already carries a mount from `{mounted_source}` \
({fs_type}), not the configured `{}` — leaving it \
alone; the ranks will read whatever is mounted \
there. Unmount it (`fusermount -u {}`) to let fdl \
mount the configured source.",
mountpoint.display(),
target.remote,
mountpoint.display(),
));
} else {
notes.push(format!(
"source root {} already mounted from `{mounted_source}` \
({fs_type})",
mountpoint.display(),
));
}
}
None => {
mount_sshfs(&target, &mountpoint, spec.ssh)?;
notes.push(format!(
"mounted `{}` read-only at {}",
target.remote,
mountpoint.display(),
));
}
}
verify_source_root(&mountpoint)?;
Ok(Some(mountpoint))
}
fn absolute(path: &str) -> Result<PathBuf, Fail> {
std::path::absolute(path)
.map_err(|e| Fail::Permanent(format!("cannot resolve data path `{path}`: {e}")))
}
fn verify_source_root(path: &Path) -> Result<(), Fail> {
if !path.is_dir() {
return Err(Fail::Permanent(format!(
"dataset source root {} is not a readable directory — \
provision it (mount or create it), point `data_path:` \
somewhere that exists, or set `data_source:` so fdl mounts \
it here",
path.display(),
)));
}
std::fs::read_dir(path).map_err(|e| {
Fail::Permanent(format!(
"dataset source root {} cannot be listed: {e}",
path.display(),
))
})?;
Ok(())
}
fn ensure_mountpoint(dir: &Path) -> Result<(), Fail> {
if dir.is_dir() {
return Ok(());
}
std::fs::create_dir_all(dir).map_err(|e| {
Fail::Permanent(format!(
"mountpoint {} does not exist and cannot be created: {e} — \
create it once during provisioning (`sudo mkdir -p {} && \
sudo chown $USER {}`), or set `data_path:` to a directory \
this user owns",
dir.display(),
dir.display(),
dir.display(),
))
})
}
fn parse_source(spec: &str) -> Result<SshTarget, Fail> {
match split_scheme(spec) {
(Some("sshfs"), rest) => parse_ssh_target(rest).map_err(|why| {
Fail::Permanent(format!(
"invalid data_source `sshfs://{rest}` — {why}. Expected \
`sshfs://[user@]host[:port]/abs/path` (or the scp spelling \
`sshfs://[user@]host:/abs/path`)"
))
}),
(Some(scheme), _) => Err(Fail::Permanent(format!(
"unsupported data_source scheme `{scheme}://` — fdl ships \
`sshfs://` today. A source another tool already mounted \
needs no scheme: name its path in `data_path:` instead"
))),
(None, _) => Err(Fail::Permanent(format!(
"data_source `{spec}` names no transport — a source that is \
already mounted goes in `data_path:`; a source fdl should \
mount needs a scheme, e.g. \
`sshfs://user@host:/flodl/data`"
))),
}
}
fn mount_sshfs(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Result<(), Fail> {
if !crate::util::system::has_command("sshfs") {
return Err(Fail::Permanent(format!(
"data_source needs sshfs, which is not installed — \
`sudo apt install sshfs` (or mount `{}` during provisioning \
and declare a bare `data_path:`)",
target.remote,
)));
}
let argv = sshfs_argv(target, mountpoint, ssh);
let out = Command::new(&argv[0])
.args(&argv[1..])
.output()
.map_err(|e| Fail::Permanent(format!("spawn sshfs: {e}")))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(Fail::Transient(format!(
"mounting `{}` at {} failed ({}): {}",
target.remote,
mountpoint.display(),
out.status,
stderr.trim(),
)));
}
if crate::probe::mounted_at(mountpoint).is_none() {
return Err(Fail::Transient(format!(
"sshfs reported success but nothing is mounted at {} — the \
far side likely dropped the connection",
mountpoint.display(),
)));
}
Ok(())
}
fn sshfs_argv(target: &SshTarget, mountpoint: &Path, ssh: Option<&SshConfig>) -> Vec<String> {
let mut argv: Vec<String> = vec![
"sshfs".into(),
target.remote.clone(),
mountpoint.display().to_string(),
];
let mut opt = |v: String| {
argv.push("-o".into());
argv.push(v);
};
if let Some(ssh) = ssh {
if let Some(warning) =
crate::cluster::batchmode_override_warning(&ssh.options, &target.remote)
{
eprintln!("{warning}");
}
for o in &ssh.options {
opt(o.clone());
}
if let Some(id) = &ssh.identity_file {
opt(format!("IdentityFile={id}"));
}
}
if let Some(port) = target.port {
opt(format!("port={port}"));
}
for o in [
"ro",
"reconnect",
"ServerAliveInterval=15",
"ServerAliveCountMax=3",
"BatchMode=yes",
] {
opt(o.to_string());
}
argv
}
fn check_local_dirs(notes: &mut Vec<String>) -> Result<(), Fail> {
match std::env::var_os("HOME") {
Some(home) => {
let cache = PathBuf::from(home).join(CACHE_SUBPATH);
check_writable("dataset cache", &cache, true, notes)?;
}
None => notes.push(
"HOME is unset, so flodl will cache datasets under the temp \
directory — on a tmpfs that spends RAM, not disk. Set HOME, \
or pre-provision the source root."
.to_string(),
),
}
check_writable("disk stage", &std::env::temp_dir(), false, notes)
}
fn check_writable(
label: &str,
dir: &Path,
create: bool,
notes: &mut Vec<String>,
) -> Result<(), Fail> {
if create {
std::fs::create_dir_all(dir).map_err(|e| {
Fail::Permanent(format!(
"{label} directory {} cannot be created: {e}",
dir.display(),
))
})?;
} else if !dir.is_dir() {
return Err(Fail::Permanent(format!(
"{label} directory {} does not exist",
dir.display(),
)));
}
let probe = dir.join(format!(
".fdl-prepare-{}-{}",
std::process::id(),
next_probe_id(),
));
let written = std::fs::write(&probe, b"fdl prepare\n");
let _ = std::fs::remove_file(&probe);
written.map_err(|e| {
Fail::Permanent(format!(
"{label} directory {} is not writable: {e} — training stages \
data there, so it must be",
dir.display(),
))
})?;
if let Some(fs_type) = crate::probe::detect_fs_type(dir)
&& (fs_type == "tmpfs" || fs_type == "ramfs")
{
notes.push(format!(
"{label} directory {} is on {fs_type} (RAM-backed) — \
staging there spends RAM, not disk",
dir.display(),
));
}
if let Some(kib) = available_kib(dir)
&& kib < LOW_SPACE_KIB
{
notes.push(format!(
"{label} directory {} has {} MiB free — smaller than any \
real corpus",
dir.display(),
kib / 1024,
));
}
Ok(())
}
fn available_kib(dir: &Path) -> Option<u64> {
let out = Command::new("df").arg("-Pk").arg(dir).output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.nth(1)?
.split_whitespace()
.nth(3)?
.parse::<u64>()
.ok()
}
fn next_probe_id() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
NEXT.fetch_add(1, Ordering::Relaxed)
}
pub fn print_notes(command: &str, notes: &[String]) {
for note in notes {
eprintln!("{}", style::dim(&format!("fdl {command}: {note}")));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gpu_gate_blocks_exactly_when_there_is_no_usable_device() {
let usable = !flodl_hw::survey_visible().devices.is_empty();
assert_eq!(
check_gpu_stack().is_ok(),
usable,
"the gate must follow the device list, not the findings",
);
}
#[test]
fn a_variant_covering_none_of_the_offered_cards_is_refused() {
let dir = std::env::temp_dir().join(format!(
"fdl-prep-arch-{}-{}",
std::process::id(),
next_probe_id(),
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
let lt = (dir.clone(), "precompiled/cu128".to_string());
let nvidia_present = flodl_hw::survey_visible()
.devices
.iter()
.any(|d| d.vendor == flodl_hw::GpuVendor::Nvidia);
match check_arch_coverage(<, None) {
Err(err) => {
assert!(nvidia_present, "refused with no matching device: {err:?}");
assert!(
err.is_permanent(),
"kernels do not grow by waiting: {err:?}"
);
assert!(err.message().contains("no kernel image"), "got: {err:?}");
}
Ok(()) => assert!(
!nvidia_present,
"an NVIDIA card offered against archs `0.0` must be refused",
),
}
assert!(check_arch_coverage(&(dir.clone(), "precompiled/cpu".into()), None).is_ok());
std::fs::remove_file(dir.join(".arch")).unwrap();
assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), None).is_ok());
std::fs::write(dir.join(".arch"), "archs=0.0\n").unwrap();
assert!(check_arch_coverage(&(dir.clone(), "precompiled/cu128".into()), Some(&[])).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_sshfs_scheme_reaches_the_shared_grammar() {
assert_eq!(
parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap(),
SshTarget {
remote: "flodl@exa:/flodl/data".into(),
port: Some(2222)
},
);
}
#[test]
fn a_published_manifest_outranks_the_boxs_own_recipe() {
let local = SourceSpec {
from: "rsync://ctrl:/srv/run/tree",
cwd: Some("stale"),
build: Some("stale-build"),
bin: Some("stale-bin"),
ssh: None,
};
let manifest = Manifest {
cwd: Some("ddp-bench".into()),
build: Some("cargo build --release".into()),
bin: "target/release/ddp-bench".into(),
..Manifest::default()
};
let recipe = merge_manifest(&local, Some(&manifest)).unwrap();
assert_eq!(recipe.cwd, Some("ddp-bench"));
assert_eq!(recipe.build, Some("cargo build --release"));
assert_eq!(recipe.bin, "target/release/ddp-bench");
}
#[test]
fn a_manifest_that_says_nothing_leaves_the_local_answer_standing() {
let local = SourceSpec {
from: "file:///mnt/rdl",
cwd: Some("ddp-bench"),
build: Some("./ci/node-build.sh"),
bin: Some("target/release/x"),
ssh: None,
};
let bare = Manifest {
bin: "target/release/y".into(),
..Manifest::default()
};
let recipe = merge_manifest(&local, Some(&bare)).unwrap();
assert_eq!(recipe.cwd, Some("ddp-bench"));
assert_eq!(recipe.build, Some("./ci/node-build.sh"));
assert_eq!(recipe.bin, "target/release/y");
let recipe = merge_manifest(&local, None).unwrap();
assert_eq!(recipe.bin, "target/release/x");
}
#[test]
fn no_manifest_and_no_local_artifact_waits_rather_than_stopping() {
let local = SourceSpec {
from: "rsync://ctrl:/srv/run/tree",
..Default::default()
};
let err = merge_manifest(&local, None).unwrap_err();
assert!(!err.is_permanent(), "the fix is a publish away: {err:?}");
assert!(err.message().contains("fdl publish"), "got: {err:?}");
}
#[test]
fn a_failed_build_is_classed_by_whether_the_toolkit_could_explain_it() {
let dir = std::env::temp_dir().join(format!(
"fdl-prep-toolkit-{}-{}",
std::process::id(),
next_probe_id(),
));
std::fs::create_dir_all(&dir).unwrap();
let fail = |variant: &str| {
let libtorch = (dir.clone(), variant.to_string());
build_source(
&Recipe {
cwd: None,
build: Some("exit 3"),
bin: "x",
},
&dir,
Some(&libtorch),
&mut Vec::new(),
)
.unwrap_err()
};
let err = fail("precompiled/rocm70");
match crate::util::requirements::toolkit_gap(flodl_hw::GpuVendor::Amd) {
Some(gap) => {
assert!(
err.is_permanent(),
"waiting cannot install a package: {err:?}"
);
assert!(
err.message().contains(&gap.install),
"the fix must be named: {err:?}"
);
}
None => assert!(
!err.is_permanent(),
"toolkit present, so a compile error stays a push away: {err:?}"
),
}
let err = fail("precompiled/cpu");
assert!(!err.is_permanent(), "got: {err:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_source_spec_rejects_every_broken_shape_permanently() {
for spec in [
"/flodl/data", "smb://server/share", "sshfs://exa", "sshfs://exa:banana/data", "sshfs://:/flodl/data", "sshfs://exa:/", ] {
let err = parse_source(spec).unwrap_err();
assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
assert!(err.message().contains("data_source"), "{spec}: {err:?}");
}
}
#[test]
fn a_bare_path_names_the_field_it_belongs_in() {
let err = parse_source("/flodl/data").unwrap_err();
assert!(err.message().contains("data_path:"), "got: {err:?}");
}
#[test]
fn sshfs_argv_puts_user_options_before_the_defaults() {
let ssh = SshConfig {
target: Some("ctrl".into()),
port: Some(2222),
user: Some("join-user".into()),
identity_file: Some("/etc/flodl/join_key".into()),
options: vec!["ServerAliveInterval=5".into()],
};
let target = parse_source("sshfs://flodl@exa:2222/flodl/data").unwrap();
let argv = sshfs_argv(&target, Path::new("/flodl/data"), Some(&ssh));
assert_eq!(argv[0], "sshfs");
assert_eq!(argv[1], "flodl@exa:/flodl/data");
assert_eq!(argv[2], "/flodl/data");
let user_pos = argv
.iter()
.position(|a| a == "ServerAliveInterval=5")
.unwrap();
let default_pos = argv
.iter()
.position(|a| a == "ServerAliveInterval=15")
.unwrap();
assert!(user_pos < default_pos);
assert!(argv.contains(&"IdentityFile=/etc/flodl/join_key".to_string()));
assert!(argv.contains(&"port=2222".to_string()));
assert!(argv.contains(&"BatchMode=yes".to_string()));
assert!(argv.contains(&"ro".to_string()));
}
#[test]
fn sshfs_argv_without_an_ssh_block_still_carries_the_defaults() {
let target = parse_source("sshfs://exa/data").unwrap();
let argv = sshfs_argv(&target, Path::new("/mnt/d"), None);
assert!(argv.contains(&"ro".to_string()));
assert!(argv.contains(&"reconnect".to_string()));
assert!(!argv.iter().any(|a| a.starts_with("IdentityFile")));
assert!(!argv.iter().any(|a| a.starts_with("port=")));
}
#[test]
fn no_data_fields_prepares_nothing() {
let mut notes = Vec::new();
let got = resolve_data_root(&DataSpec::default(), &mut notes).unwrap();
assert_eq!(
got, None,
"a run that never mentions data must ship nothing"
);
assert!(notes.is_empty());
}
#[test]
fn a_relative_declared_path_is_shipped_absolute() {
let cwd = std::env::current_dir().unwrap();
let name = format!("fdl-prep-rel-{}-{}", std::process::id(), next_probe_id());
let dir = cwd.join(&name);
std::fs::create_dir_all(&dir).unwrap();
let spec = DataSpec {
path: Some(&name),
..Default::default()
};
let got = resolve_data_root(&spec, &mut Vec::new()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(got, Some(dir));
}
#[test]
fn a_declared_path_is_verified_and_returned() {
let dir = std::env::temp_dir().join(format!(
"fdl-prep-src-{}-{}",
std::process::id(),
next_probe_id(),
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.display().to_string();
let mut notes = Vec::new();
let spec = DataSpec {
path: Some(&path),
..Default::default()
};
assert_eq!(
resolve_data_root(&spec, &mut notes).unwrap(),
Some(dir.clone()),
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_declared_path_that_is_not_there_is_permanent() {
let missing = std::env::temp_dir()
.join("fdl-prep-absent-do-not-create")
.display()
.to_string();
let spec = DataSpec {
path: Some(&missing),
..Default::default()
};
let err = resolve_data_root(&spec, &mut Vec::new()).unwrap_err();
assert!(err.is_permanent(), "got: {err:?}");
assert!(err.message().contains("data_source:"), "got: {err:?}");
}
#[test]
fn a_readable_source_root_needs_no_write_permission() {
let dir = std::env::temp_dir().join(format!(
"fdl-prep-ro-{}-{}",
std::process::id(),
next_probe_id(),
));
std::fs::create_dir_all(&dir).unwrap();
#[allow(unused_mut)]
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o555);
}
std::fs::set_permissions(&dir, perms).unwrap();
assert!(verify_source_root(&dir).is_ok());
#[allow(unused_mut)]
let mut perms = std::fs::metadata(&dir).unwrap().permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
}
std::fs::set_permissions(&dir, perms).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_writable_directory_passes_and_leaves_no_probe_file_behind() {
let dir = std::env::temp_dir().join(format!(
"fdl-prep-w-{}-{}",
std::process::id(),
next_probe_id(),
));
let mut notes = Vec::new();
check_writable("test", &dir, true, &mut notes).unwrap();
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
leftovers.is_empty(),
"probe file left behind: {leftovers:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_directory_we_must_not_create_is_permanent() {
let dir = std::env::temp_dir().join("fdl-prep-absent-stage-dir");
let err = check_writable("disk stage", &dir, false, &mut Vec::new()).unwrap_err();
assert!(err.is_permanent(), "got: {err:?}");
}
#[test]
fn free_space_reads_back_for_a_directory_that_exists() {
if !crate::util::system::has_command("df") {
return;
}
let kib = available_kib(&std::env::temp_dir());
assert!(kib.is_some_and(|k| k > 0), "got: {kib:?}");
}
}