use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::config::SshConfig;
use crate::prepare::Fail;
use crate::spec::{SshTarget, parse_ssh_target, split_scheme};
const RSYNC_EXCLUDES: [&str; 3] = ["target/", "libtorch/", ".git/"];
const ROOT_ANCHORED: [&str; 1] = ["libtorch/"];
const DEFAULT_BUILD: &str = "cargo build --release";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Local(PathBuf),
Rsync(SshTarget),
Git { url: String, git_ref: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Built {
pub bin: PathBuf,
pub cwd: PathBuf,
}
pub const MANIFEST_FILE: &str = ".fdl-run.yml";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<String>,
pub bin: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rustc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_epoch: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
#[serde(default)]
pub built: bool,
}
impl Manifest {
pub fn read(tree: &Path) -> Result<Option<Manifest>, Fail> {
let path = tree.join(MANIFEST_FILE);
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(Fail::Permanent(format!(
"cannot read the run manifest {}: {e}",
path.display(),
)));
}
};
serde_yaml_ng::from_str(&text)
.map(Some)
.map_err(|e| Fail::Permanent(format!("{} is not a run manifest: {e}", path.display())))
}
pub fn write(&self, tree: &Path) -> Result<(), Fail> {
let path = tree.join(MANIFEST_FILE);
let body = serde_yaml_ng::to_string(self)
.map_err(|e| Fail::Permanent(format!("cannot serialize the run manifest: {e}")))?;
std::fs::write(
&path,
format!(
"# Written by `fdl publish`. The controller is the authority \
for a run:\n# a worker merges this over its own config, \
because args must match the run\n# (rank children re-enter \
the binary with them). Do not hand-edit — the next\n# \
publish overwrites it, and its presence is what tells a \
worker the run\n# is ready.\n{body}"
),
)
.map_err(|e| {
Fail::Permanent(format!(
"cannot write the run manifest {}: {e}",
path.display()
))
})
}
pub fn remove(tree: &Path) -> Result<(), Fail> {
match std::fs::remove_file(tree.join(MANIFEST_FILE)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Fail::Permanent(format!(
"cannot clear the run manifest: {e}"
))),
}
}
}
pub fn parse(spec: &str) -> Result<Source, Fail> {
let forms = "Expected `file:///abs/path`, \
`rsync://[user@]host[:port]:/abs/path`, or \
`git+https://host/owner/repo#<tag|branch|sha>`";
match split_scheme(spec) {
(Some("file"), rest) => {
if !rest.starts_with('/') {
return Err(Fail::Permanent(format!(
"invalid source `{spec}` — a `file://` path must be \
absolute (three slashes: `file:///srv/train`). {forms}"
)));
}
Ok(Source::Local(PathBuf::from(rest)))
}
(Some("rsync"), rest) => parse_ssh_target(rest)
.map(Source::Rsync)
.map_err(|why| Fail::Permanent(format!("invalid source `{spec}` — {why}. {forms}"))),
(Some(scheme), rest)
if scheme == "git+https" || scheme == "git+ssh" || scheme == "git+file" =>
{
parse_git(scheme, rest, spec, forms)
}
(Some(scheme), _) => Err(Fail::Permanent(format!(
"unsupported source scheme `{scheme}://` — {forms}"
))),
(None, _) => Err(Fail::Permanent(format!(
"source `{spec}` names no transport — a directory already on \
this box is `file://` plus its absolute path. {forms}"
))),
}
}
fn parse_git(scheme: &str, rest: &str, spec: &str, forms: &str) -> Result<Source, Fail> {
let transport = scheme.trim_start_matches("git+");
let (path, git_ref) = rest.split_once('#').ok_or_else(|| {
Fail::Permanent(format!(
"source `{spec}` names no ref — add `#<tag|branch|sha>`. \
Without one the remote's default branch decides what a box \
builds, which is not a pin: two boxes provisioned an hour \
apart would not agree. {forms}"
))
})?;
if git_ref.is_empty() {
return Err(Fail::Permanent(format!(
"source `{spec}` ends at `#` with no ref. {forms}"
)));
}
if path.is_empty() {
return Err(Fail::Permanent(format!(
"source `{spec}` names no repository. {forms}"
)));
}
Ok(Source::Git {
url: format!("{transport}://{path}"),
git_ref: git_ref.to_string(),
})
}
pub fn materialize(
source: &Source,
dest: &Path,
ssh: Option<&SshConfig>,
notes: &mut Vec<String>,
) -> Result<(), Fail> {
std::fs::create_dir_all(dest).map_err(|e| {
Fail::Permanent(format!(
"cannot create source directory {}: {e}",
dest.display()
))
})?;
match source {
Source::Local(path) => {
if !path.is_dir() {
return Err(Fail::Permanent(format!(
"source {} is not a readable directory — provision it, \
or point `from:` somewhere that exists",
path.display(),
)));
}
run_rsync(
&rsync_argv(&format!("{}/", path.display()), dest, None, None),
dest,
)?;
notes.push(format!(
"source: copied {} into {}",
path.display(),
dest.display()
));
}
Source::Rsync(target) => {
let argv = rsync_argv(&format!("{}/", target.remote), dest, Some(target), ssh);
run_rsync(&argv, dest)?;
notes.push(format!(
"source: pulled {} into {}",
target.remote,
dest.display()
));
}
Source::Git { url, git_ref } => {
run_git(url, git_ref, dest)?;
notes.push(format!(
"source: checked out {url} at {git_ref} in {}",
dest.display()
));
}
}
Ok(())
}
fn rsync_argv(
src: &str,
dest: &Path,
target: Option<&SshTarget>,
ssh: Option<&SshConfig>,
) -> Vec<String> {
let mut argv: Vec<String> = vec!["rsync".into(), "-a".into(), "--delete".into()];
for ex in RSYNC_EXCLUDES {
let anchor = if ROOT_ANCHORED.contains(&ex) { "/" } else { "" };
argv.push(format!("--exclude={anchor}{ex}"));
}
if let Some(target) = target {
let mut ssh_cmd = String::from("ssh");
if let Some(port) = target.port {
ssh_cmd.push_str(&format!(" -p {port}"));
}
if let Some(ssh) = ssh {
if let Some(id) = &ssh.identity_file {
ssh_cmd.push_str(&format!(" -i {id}"));
}
for opt in &ssh.options {
ssh_cmd.push_str(&format!(" -o {opt}"));
}
}
ssh_cmd.push_str(" -o BatchMode=yes");
argv.push("-e".into());
argv.push(ssh_cmd);
}
argv.push(src.to_string());
argv.push(format!("{}/", dest.display()));
argv
}
fn run_rsync(argv: &[String], dest: &Path) -> Result<(), Fail> {
if !crate::util::system::has_command("rsync") {
return Err(Fail::Permanent(
"a source spec needs rsync, which is not installed — \
`sudo apt install rsync` (it is what preserves mtimes, so \
cargo stays incremental instead of rebuilding everything \
every dial)"
.to_string(),
));
}
let out = Command::new(&argv[0])
.args(&argv[1..])
.output()
.map_err(|e| Fail::Permanent(format!("spawn rsync: {e}")))?;
if !out.status.success() {
return Err(Fail::Transient(format!(
"fetching the source into {} failed ({}): {} — check the \
remote path, the key, and whether that key's forced command \
permits rsync (a join key guardrailed with \
`command=\"/usr/sbin/nologin\"` does not)",
dest.display(),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
)));
}
Ok(())
}
fn run_git(url: &str, git_ref: &str, dest: &Path) -> Result<(), Fail> {
if !crate::util::system::has_command("git") {
return Err(Fail::Permanent(
"a `git+` source spec needs git, which is not installed — \
`sudo apt install git`"
.to_string(),
));
}
let dest_s = dest.display().to_string();
git(&["init", "--quiet", &dest_s], "initialise")?;
let fetch = git_output(&[
"-C", &dest_s, "fetch", "--quiet", "--depth", "1", url, git_ref,
]);
match fetch {
Ok(()) => {}
Err(stderr) => {
if stderr.contains("unadvertised object") || stderr.contains("allow request for") {
return Err(Fail::Permanent(format!(
"the server refused a shallow fetch of `{git_ref}` \
({url}): fetching a bare commit needs \
`uploadpack.allowReachableSHA1InWant` on the remote. \
Name a tag or branch instead, or push the commit to \
a ref. ({stderr})"
)));
}
return Err(Fail::Transient(format!(
"fetching {url} at `{git_ref}` failed: {stderr}"
)));
}
}
git(
&[
"-C",
&dest_s,
"checkout",
"--quiet",
"--detach",
"--force",
"FETCH_HEAD",
],
"check out",
)?;
Ok(())
}
fn git(args: &[&str], what: &str) -> Result<(), Fail> {
git_output(args).map_err(|stderr| {
Fail::Permanent(format!("git failed to {what} the source tree: {stderr}"))
})
}
fn git_output(args: &[&str]) -> Result<(), String> {
let out = Command::new("git")
.args(args)
.output()
.map_err(|e| format!("spawn git: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("exited {}", out.status)
} else {
stderr
})
}
pub fn build_env(libtorch: Option<&(PathBuf, String)>) -> Vec<(String, String)> {
let Some((dir, variant)) = libtorch else {
return Vec::new();
};
let lib = dir.join("lib").display().to_string();
let vendor = crate::libtorch::detect::variant_vendor(variant);
vec![
("LIBTORCH_PATH".to_string(), dir.display().to_string()),
(
"FDL_GPU_FEATURE".to_string(),
vendor
.map(|v| v.cargo_feature().to_string())
.unwrap_or_default(),
),
(
"LD_LIBRARY_PATH".to_string(),
crate::libtorch::detect::ld_library_path_value(
vendor,
&lib,
&crate::libtorch::detect::local_rocm_lib_dir(),
),
),
]
}
pub fn build(
tree: &Path,
cwd: Option<&str>,
cmd: Option<&str>,
bin: &str,
env: &[(String, String)],
notes: &mut Vec<String>,
) -> Result<Built, Fail> {
let dir = run_recipe(tree, cwd, cmd, env, notes)?;
let path = dir.join(bin);
if !path.is_file() {
return Err(Fail::Permanent(format!(
"the build succeeded but `bin: {bin}` is not there ({}) — it \
is the artifact path relative to `cwd:`, e.g. \
`target/release/<name>`",
path.display(),
)));
}
Ok(Built {
bin: path,
cwd: dir,
})
}
pub fn check_build(
tree: &Path,
cwd: Option<&str>,
cmd: Option<&str>,
env: &[(String, String)],
notes: &mut Vec<String>,
) -> Result<(), Fail> {
run_recipe(tree, cwd, cmd, env, notes).map(|_| ())
}
fn run_recipe(
tree: &Path,
cwd: Option<&str>,
cmd: Option<&str>,
env: &[(String, String)],
notes: &mut Vec<String>,
) -> Result<PathBuf, Fail> {
let dir = match cwd {
Some(sub) => tree.join(sub),
None => tree.to_path_buf(),
};
if !dir.is_dir() {
return Err(Fail::Permanent(format!(
"`cwd: {}` names no directory in the fetched source ({}) — it \
is a path inside the tree, not on this box",
cwd.unwrap_or(""),
dir.display(),
)));
}
let recipe = cmd.unwrap_or(DEFAULT_BUILD);
if cmd.is_none() && !crate::util::system::has_command("cargo") {
return Err(Fail::Permanent(
"building the source needs cargo, which is not installed — \
install a toolchain (https://rustup.rs), or set `build:` to \
a recipe that does not need one"
.to_string(),
));
}
notes.push(format!("source: building in {} — {recipe}", dir.display()));
let mut command = Command::new("sh");
command.args(["-c", recipe]).current_dir(&dir);
for (k, v) in env {
command.env(k, v);
}
let status = command
.status()
.map_err(|e| Fail::Permanent(format!("spawn `{recipe}`: {e}")))?;
if !status.success() {
return Err(Fail::Transient(format!(
"the source does not build ({status}) — see the compiler \
output above"
)));
}
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_spelling_parses() {
assert_eq!(
parse("file:///srv/train").unwrap(),
Source::Local("/srv/train".into())
);
assert_eq!(
parse("rsync://flodl@exa:/home/op/train").unwrap(),
Source::Rsync(SshTarget {
remote: "flodl@exa:/home/op/train".into(),
port: None
}),
);
assert_eq!(
parse("rsync://exa:2222/home/op/train").unwrap(),
Source::Rsync(SshTarget {
remote: "exa:/home/op/train".into(),
port: Some(2222)
}),
);
assert_eq!(
parse("rsync://exa:2222:/home/op/train").unwrap(),
Source::Rsync(SshTarget {
remote: "exa:/home/op/train".into(),
port: Some(2222)
}),
);
assert_eq!(
parse("git+https://github.com/flodl-labs/flodl#0.7.0").unwrap(),
Source::Git {
url: "https://github.com/flodl-labs/flodl".into(),
git_ref: "0.7.0".into(),
},
);
assert_eq!(
parse("git+ssh://git@github.com/me/train#feature/wip").unwrap(),
Source::Git {
url: "ssh://git@github.com/me/train".into(),
git_ref: "feature/wip".into(),
},
);
}
#[test]
fn the_git_resolver_fetches_a_ref_and_then_moves_to_another() {
if !crate::util::system::has_command("git") {
return;
}
let base = std::env::temp_dir().join(format!("fdl-src-git-{}", std::process::id()));
let (origin, dest) = (base.join("origin"), base.join("dest"));
std::fs::create_dir_all(&origin).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&origin)
.env("GIT_AUTHOR_NAME", "fdl")
.env("GIT_AUTHOR_EMAIL", "fdl@example.com")
.env("GIT_COMMITTER_NAME", "fdl")
.env("GIT_COMMITTER_EMAIL", "fdl@example.com")
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "--quiet"]);
std::fs::write(origin.join("main.rs"), "// one").unwrap();
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "one"]);
git(&["tag", "v1"]);
std::fs::write(origin.join("main.rs"), "// two").unwrap();
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "two"]);
git(&["tag", "v2"]);
let url = format!("git+file://{}", origin.display());
let at_v1 = parse(&format!("{url}#v1")).unwrap();
materialize(&at_v1, &dest, None, &mut Vec::new()).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("main.rs")).unwrap(),
"// one"
);
std::fs::create_dir_all(dest.join("target/release")).unwrap();
std::fs::write(dest.join("target/release/train"), "binary").unwrap();
let at_v2 = parse(&format!("{url}#v2")).unwrap();
materialize(&at_v2, &dest, None, &mut Vec::new()).unwrap();
assert_eq!(
std::fs::read_to_string(dest.join("main.rs")).unwrap(),
"// two"
);
assert!(
dest.join("target/release/train").is_file(),
"the checkout swept the build"
);
let missing = parse(&format!("{url}#v9")).unwrap();
assert!(materialize(&missing, &dest, None, &mut Vec::new()).is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_broken_spec_is_permanent_and_names_the_forms() {
for spec in [
"/srv/train", "file://srv/train", "smb://server/share", "rsync://exa", "git+https://github.com/me/train", "git+https://github.com/me/train#", "git+ssh://#0.7.0", ] {
let err = parse(spec).unwrap_err();
assert!(err.is_permanent(), "{spec} should be permanent: {err:?}");
assert!(
err.message().contains("file:///") || err.message().contains("`#<"),
"{spec} should name the accepted forms: {err:?}",
);
}
}
#[test]
fn a_missing_ref_explains_why_a_default_branch_is_not_a_pin() {
let err = parse("git+https://github.com/me/train").unwrap_err();
assert!(err.message().contains("not a pin"), "got: {err:?}");
}
#[test]
fn rsync_argv_preserves_times_and_protects_the_target_dir() {
let argv = rsync_argv("/mnt/rdl/", Path::new("/home/op/.flodl/source"), None, None);
assert_eq!(argv[0], "rsync");
assert!(argv.contains(&"-a".to_string()));
assert!(argv.contains(&"--delete".to_string()));
assert!(argv.contains(&"--exclude=target/".to_string()));
assert!(!argv.contains(&"--exclude=/target/".to_string()));
assert!(argv.contains(&"--exclude=/libtorch/".to_string()));
assert_eq!(argv[argv.len() - 2], "/mnt/rdl/");
assert_eq!(argv[argv.len() - 1], "/home/op/.flodl/source/");
assert!(!argv.contains(&"-e".to_string()));
}
#[test]
fn rsync_argv_carries_the_ssh_hops_port_key_and_options() {
let ssh = SshConfig {
target: Some("exa".into()),
port: Some(22),
user: None,
identity_file: Some("/etc/flodl/join_key".into()),
options: vec!["StrictHostKeyChecking=accept-new".into()],
};
let target = SshTarget {
remote: "op@exa:/srv/train".into(),
port: Some(2222),
};
let argv = rsync_argv(
"op@exa:/srv/train/",
Path::new("/t"),
Some(&target),
Some(&ssh),
);
let e = argv
.iter()
.position(|a| a == "-e")
.expect("-e for a remote source");
let cmd = &argv[e + 1];
assert!(cmd.contains("-p 2222"), "got: {cmd}");
assert!(cmd.contains("-i /etc/flodl/join_key"), "got: {cmd}");
assert!(
cmd.contains("-o StrictHostKeyChecking=accept-new"),
"got: {cmd}"
);
assert!(cmd.contains("-o BatchMode=yes"), "got: {cmd}");
}
#[test]
fn a_refetch_keeps_an_old_mtime_and_a_nested_build() {
if !crate::util::system::has_command("rsync") {
return;
}
let base = std::env::temp_dir().join(format!("fdl-src-refetch-{}", std::process::id()));
let (src, dest) = (base.join("src"), base.join("dest"));
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/lib.rs"), "fn main() {}").unwrap();
std::fs::write(src.join("gone.txt"), "temporary").unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(86_400);
std::fs::File::options()
.write(true)
.open(src.join("sub/lib.rs"))
.unwrap()
.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
let source = Source::Local(src.clone());
materialize(&source, &dest, None, &mut Vec::new()).unwrap();
std::fs::create_dir_all(dest.join("sub/target/release")).unwrap();
std::fs::write(dest.join("sub/target/release/train"), "binary").unwrap();
std::fs::remove_file(src.join("gone.txt")).unwrap();
materialize(&source, &dest, None, &mut Vec::new()).unwrap();
assert!(
dest.join("sub/target/release/train").is_file(),
"the refetch deleted a nested build",
);
assert!(
!dest.join("gone.txt").exists(),
"--delete must drop a removed file"
);
let copied = std::fs::metadata(dest.join("sub/lib.rs"))
.unwrap()
.modified()
.unwrap();
let drift = copied.duration_since(old).unwrap_or_default();
assert!(
drift < std::time::Duration::from_secs(2),
"the fetch restamped the file ({drift:?} newer than the source)",
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_cwd_outside_the_fetched_tree_is_permanent() {
let tree = std::env::temp_dir().join("fdl-src-no-such-tree");
let err = build(&tree, Some("nope"), Some("true"), "x", &[], &mut Vec::new()).unwrap_err();
assert!(err.is_permanent(), "got: {err:?}");
assert!(err.message().contains("inside the tree"), "got: {err:?}");
}
#[test]
fn a_build_that_fails_is_transient_so_the_fleet_survives_a_typo() {
let dir = std::env::temp_dir().join(format!("fdl-src-build-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let err = build(&dir, None, Some("exit 3"), "bin", &[], &mut Vec::new()).unwrap_err();
assert!(
!err.is_permanent(),
"a compile error must not stop the box: {err:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_build_that_produces_nothing_at_bin_is_permanent() {
let dir = std::env::temp_dir().join(format!("fdl-src-nobin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let err = build(
&dir,
None,
Some("true"),
"target/release/x",
&[],
&mut Vec::new(),
)
.unwrap_err();
assert!(err.is_permanent(), "got: {err:?}");
assert!(err.message().contains("bin:"), "got: {err:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_env_reaches_the_recipe_and_the_binary_is_returned() {
let dir = std::env::temp_dir().join(format!("fdl-src-env-{}", std::process::id()));
std::fs::create_dir_all(dir.join("sub")).unwrap();
let env = vec![("FDL_TEST_MARKER".to_string(), "ok".to_string())];
let built = build(
&dir,
Some("sub"),
Some("printf %s \"$FDL_TEST_MARKER\" > out"),
"out",
&env,
&mut Vec::new(),
)
.unwrap();
assert_eq!(built.cwd, dir.join("sub"));
assert_eq!(built.bin, dir.join("sub").join("out"));
assert_eq!(std::fs::read_to_string(&built.bin).unwrap(), "ok");
let _ = std::fs::remove_dir_all(&dir);
}
}