1use std::net::{TcpListener, TcpStream};
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::time::{Duration, Instant};
32
33use crate::builtins::JoinArgs;
34use crate::config::{self, DEFAULT_CONTROLLER_PORT, SshConfig, WorkerJoin, WorkerSource};
35use crate::context::Context;
36use crate::prepare::{self, DataSpec, Fail, PrepareSpec, Prepared, SourceSpec};
37use crate::style;
38
39const ENV_AGENT_JSON: &str = "FLODL_INTERNAL_AGENT_JSON";
43
44pub const EXIT_PERMANENT: i32 = 2;
59
60const TUNNEL_READY_BUDGET: Duration = Duration::from_secs(20);
62
63const BACKOFF_MIN: Duration = Duration::from_secs(5);
67const BACKOFF_MAX: Duration = Duration::from_secs(60);
68const BACKOFF_RESET_AFTER: Duration = Duration::from_secs(120);
69
70pub fn run(cli: &JoinArgs, bin_tail: Option<&[String]>) -> i32 {
81 let (block, project_root) = match load_join_block() {
82 Ok(pair) => pair,
83 Err(e) => {
84 crate::cli_error!("{e}");
85 return EXIT_PERMANENT;
86 }
87 };
88 let eff = match resolve_effective(
89 cli,
90 bin_tail,
91 block,
92 &crate::cluster::resolve_local_hostname(),
93 ) {
94 Ok(eff) => eff,
95 Err(e) => {
96 crate::cli_error!("{e}");
97 return EXIT_PERMANENT;
98 }
99 };
100 if eff.controller_defaulted {
101 eprintln!(
102 "{}",
103 style::dim(&format!(
104 "fdl join: no controller configured; dialing \
105 127.0.0.1:{DEFAULT_CONTROLLER_PORT} (pass an address or set \
106 `join.controller` in fdl.yml)"
107 )),
108 );
109 }
110
111 if let BinSource::Given(path) = &eff.bin
116 && !Path::new(path).is_file()
117 {
118 crate::cli_error!(
119 "training binary not found: {path} — build it first and \
120 point `--bin` (or fdl.yml `join.bin`) at it, or hand this \
121 box a `--source` to build",
122 );
123 return EXIT_PERMANENT;
124 }
125
126 let libtorch = resolve_local_libtorch(project_root.as_deref());
133
134 let mut sig_cache: Option<(u64, Option<String>)> = None;
141
142 let mut backoff = BACKOFF_MIN;
143 loop {
144 let started = Instant::now();
145 let outcome = match attempt(&eff, libtorch.as_ref(), &mut sig_cache) {
148 Ok(code) => {
149 if !eff.persist {
150 return code;
151 }
152 format!("agent exited with code {code}")
153 }
154 Err(fail) => {
155 crate::cli_error!("{}", fail.message());
156 if fail.is_permanent() {
157 eprintln!(
160 "{}",
161 style::dim(&format!(
162 "fdl join: not re-dialing — retrying cannot \
163 fix this (exit {EXIT_PERMANENT})"
164 )),
165 );
166 return EXIT_PERMANENT;
167 }
168 if !eff.persist {
169 return 1;
170 }
171 "attempt failed".to_string()
172 }
173 };
174 if started.elapsed() > BACKOFF_RESET_AFTER {
175 backoff = BACKOFF_MIN;
176 }
177 eprintln!(
178 "fdl join: {outcome} after {}s; re-dialing in {}s (--persist)",
179 started.elapsed().as_secs(),
180 backoff.as_secs(),
181 );
182 std::thread::sleep(backoff);
183 backoff = (backoff * 2).min(BACKOFF_MAX);
184 }
185}
186
187#[derive(Debug)]
194struct Effective {
195 controller_host: String,
198 controller_port: u16,
199 controller_defaulted: bool,
202 ssh: Option<SshConfig>,
204 token: Option<String>,
206 bin: BinSource,
209 libtorch_spec: Option<String>,
211 host: String,
213 devices: Option<Vec<u8>>,
215 persist: bool,
216 bin_args: Vec<String>,
218 data_path: Option<String>,
221 data_source: Option<String>,
223 gpu_ram_share: Option<f64>,
226 sig_probe: bool,
229}
230
231#[derive(Debug, PartialEq, Eq)]
237enum BinSource {
238 Given(String),
240 Build(WorkerSource),
242}
243
244impl Effective {
245 fn prepare_spec<'a>(
250 &'a self,
251 active_libtorch: Option<&'a (PathBuf, String)>,
252 ) -> PrepareSpec<'a> {
253 PrepareSpec {
254 data: DataSpec {
255 path: self.data_path.as_deref(),
256 source: self.data_source.as_deref(),
257 ssh: self.ssh.as_ref(),
258 },
259 libtorch: self.libtorch_spec.as_deref(),
260 active_libtorch,
261 devices: self.devices.as_deref(),
262 source: match &self.bin {
263 BinSource::Given(_) => None,
264 BinSource::Build(s) => Some(SourceSpec {
265 from: &s.from,
266 cwd: s.cwd.as_deref(),
267 build: s.build.as_deref(),
268 bin: s.bin.as_deref(),
269 ssh: self.ssh.as_ref(),
270 }),
271 },
272 }
273 }
274}
275
276fn resolve_effective(
279 cli: &JoinArgs,
280 bin_tail: Option<&[String]>,
281 block: Option<WorkerJoin>,
282 local_hostname: &str,
283) -> Result<Effective, String> {
284 let block = block.unwrap_or_default();
285
286 if cli.identity.is_some() && cli.ssh.is_none() && block.ssh.is_none() {
287 return Err("--identity is the tunnel's key file — it needs an ssh hop \
288 (`--ssh` or fdl.yml `join.ssh`)"
289 .to_string());
290 }
291
292 let ssh = match (&cli.ssh, block.ssh) {
297 (Some(spec), b) => {
298 let mut cfg = parse_ssh_spec(spec)?;
299 if let Some(b) = b {
300 cfg.identity_file = b.identity_file;
301 cfg.options = b.options;
302 }
303 Some(cfg)
304 }
305 (None, Some(b)) => {
306 if b.target.is_none() {
307 return Err("fdl.yml join.ssh needs a `target:` (the tunnel host)".to_string());
308 }
309 Some(b)
310 }
311 (None, None) => None,
312 };
313 let mut ssh = ssh;
314 if let (Some(cfg), Some(id)) = (ssh.as_mut(), &cli.identity) {
315 cfg.identity_file = Some(id.clone());
316 }
317
318 let named = cli.controller.as_ref().or(block.controller.as_ref());
323 let controller_defaulted = named.is_none() && ssh.is_none();
324 let (controller_host, controller_port) = match named {
325 Some(spec) => parse_host_port(spec)?,
326 None => ("127.0.0.1".to_string(), DEFAULT_CONTROLLER_PORT),
327 };
328
329 let bin_path = cli.bin.clone().or(block.bin);
334 let source = match (cli.source.clone(), block.source) {
335 (Some(from), b) => Some(WorkerSource {
336 from,
337 cwd: cli
341 .source_cwd
342 .clone()
343 .or_else(|| b.as_ref().and_then(|b| b.cwd.clone())),
344 build: cli
345 .source_build
346 .clone()
347 .or_else(|| b.as_ref().and_then(|b| b.build.clone())),
348 bin: cli
352 .source_bin
353 .clone()
354 .or_else(|| b.as_ref().and_then(|b| b.bin.clone())),
355 }),
356 (None, Some(mut b)) => {
357 if let Some(cwd) = cli.source_cwd.clone() {
358 b.cwd = Some(cwd);
359 }
360 if let Some(build) = cli.source_build.clone() {
361 b.build = Some(build);
362 }
363 if let Some(bin) = cli.source_bin.clone() {
364 b.bin = Some(bin);
365 }
366 Some(b)
367 }
368 (None, None) => None,
369 };
370 if source.is_none() {
374 for (flag, set) in [
375 ("--source-cwd", cli.source_cwd.is_some()),
376 ("--source-build", cli.source_build.is_some()),
377 ("--source-bin", cli.source_bin.is_some()),
378 ] {
379 if set {
380 return Err(format!(
381 "{flag} has no source to apply to — pass `--source \
382 <spec>` too, or set `join.source` in fdl.yml"
383 ));
384 }
385 }
386 }
387
388 let bin = match (bin_path, source) {
389 (Some(_), Some(_)) => {
390 return Err("`bin:` and `source:` both name this box's training binary \
391 — keep the one you mean. `bin:` runs a binary as given; \
392 `source:` fetches and builds one here"
393 .to_string());
394 }
395 (Some(path), None) => BinSource::Given(path),
396 (None, Some(source)) => BinSource::Build(source),
397 (None, None) => {
398 return Err("no training binary configured — pass `--bin <path>` (run \
399 it as given) or `--source <spec>` (build it here), or set \
400 `join.bin` / `join.source` in fdl.yml. The binary is the \
401 protocol: it dials, joins, and runs this host's ranks"
402 .to_string());
403 }
404 };
405
406 let devices = match &cli.devices {
407 Some(spec) => parse_devices(spec)?,
408 None => block.devices,
409 };
410
411 let bin_args = match bin_tail {
414 Some(tail) => tail.to_vec(),
415 None => block.args,
416 };
417
418 Ok(Effective {
419 controller_host,
420 controller_port,
421 controller_defaulted,
422 ssh,
423 token: cli.token.clone().or(block.token),
424 bin,
425 host: cli
426 .host
427 .clone()
428 .or(block.host)
429 .unwrap_or_else(|| local_hostname.to_string()),
430 devices,
431 persist: cli.persist || block.persist,
432 bin_args,
433 libtorch_spec: cli.libtorch.clone().or(block.libtorch),
434 data_path: cli.data_path.clone().or(block.data_path),
435 data_source: cli.data_source.clone().or(block.data_source),
436 gpu_ram_share: cli.gpu_ram_share.or(block.gpu_ram_share),
437 sig_probe: !cli.no_sig_probe && block.sig_probe.unwrap_or(true),
440 })
441}
442
443fn load_join_block() -> Result<(Option<WorkerJoin>, Option<PathBuf>), String> {
455 let cwd =
456 std::env::current_dir().map_err(|e| format!("cannot read the current directory: {e}"))?;
457 let Some(config_path) = config::find_project_config(&cwd) else {
458 return Ok((None, None));
459 };
460 let env_name = std::env::var("FDL_ENV")
461 .ok()
462 .filter(|s| !s.trim().is_empty());
463 let project = config::load_project_with_env(&config_path, env_name.as_deref())
464 .map_err(|e| format!("cannot load {}: {e}", config_path.display()))?;
465 let root = config_path.parent().map(Path::to_path_buf);
466 Ok((project.join, root))
467}
468
469fn parse_host_port(spec: &str) -> Result<(String, u16), String> {
472 match spec.rsplit_once(':') {
473 Some((host, port)) => {
474 let port = port.parse::<u16>().map_err(|_| {
475 format!("invalid controller address `{spec}` — expected host[:port]")
476 })?;
477 if host.is_empty() {
478 return Err(format!(
479 "invalid controller address `{spec}` — expected host[:port]"
480 ));
481 }
482 Ok((host.to_string(), port))
483 }
484 None => Ok((spec.to_string(), DEFAULT_CONTROLLER_PORT)),
485 }
486}
487
488fn parse_ssh_spec(spec: &str) -> Result<SshConfig, String> {
492 let (user, rest) = match spec.split_once('@') {
493 Some((u, r)) if !u.is_empty() => (Some(u.to_string()), r),
494 Some(_) => {
495 return Err(format!("invalid --ssh `{spec}` — empty user before `@`"));
496 }
497 None => (None, spec),
498 };
499 let (host, port) = match rest.rsplit_once(':') {
500 Some((h, p)) => {
501 let port = p
502 .parse::<u16>()
503 .map_err(|_| format!("invalid --ssh `{spec}` — expected [user@]host[:port]"))?;
504 (h, Some(port))
505 }
506 None => (rest, None),
507 };
508 if host.is_empty() {
509 return Err(format!(
510 "invalid --ssh `{spec}` — expected [user@]host[:port]"
511 ));
512 }
513 Ok(SshConfig {
514 target: Some(host.to_string()),
515 port,
516 user,
517 identity_file: None,
518 options: Vec::new(),
519 })
520}
521
522fn parse_devices(spec: &str) -> Result<Option<Vec<u8>>, String> {
525 if spec.trim().eq_ignore_ascii_case("all") {
526 return Ok(None);
527 }
528 spec.split(',')
529 .map(|s| {
530 s.trim()
531 .parse::<u8>()
532 .map_err(|_| format!("invalid --devices `{spec}` — expected e.g. `0,1` or `all`"))
533 })
534 .collect::<Result<Vec<u8>, String>>()
535 .map(Some)
536}
537
538fn agent_spec_hex(
553 eff: &Effective,
554 dial: (&str, u16),
555 libtorch_label: &str,
556 prepared: &Prepared,
557 model_sig_hex: Option<&str>,
558) -> String {
559 let mut spec = serde_json::json!({
560 "host": eff.host,
561 "controller_host": dial.0,
562 "controller_port": dial.1,
563 "libtorch": libtorch_label,
564 });
565 if let Some(token) = &eff.token {
566 spec["salt_hex"] = serde_json::json!(token);
567 }
568 if let Some(devices) = &eff.devices {
569 spec["local_devices"] = serde_json::json!(devices);
570 }
571 if let Some(data) = &prepared.data_path {
572 spec["data_path"] = serde_json::json!(data.display().to_string());
573 }
574 if let Some(run) = &prepared.run_id {
575 spec["run_id"] = serde_json::json!(run);
576 }
577 if let Some(share) = eff.gpu_ram_share {
582 spec["gpu_ram_share"] = serde_json::json!(share);
583 }
584 if let Some(sig) = model_sig_hex {
588 spec["model_sig_hex"] = serde_json::json!(sig);
589 }
590 hex_encode(spec.to_string().as_bytes())
591}
592
593fn hex_encode(bytes: &[u8]) -> String {
595 let mut s = String::with_capacity(bytes.len() * 2);
596 for b in bytes {
597 s.push_str(&format!("{b:02x}"));
598 }
599 s
600}
601
602fn resolve_local_libtorch(project_root: Option<&Path>) -> Option<(PathBuf, String)> {
607 let root = match project_root {
608 Some(r) => r.to_path_buf(),
609 None => Context::resolve().root,
610 };
611 crate::libtorch::detect::active_variant(&root)
612}
613
614fn child_ld_library_path(libtorch_dir: &Path, variant: &str) -> String {
624 let lib = libtorch_dir.join("lib").display().to_string();
625 let vendor = crate::libtorch::detect::variant_vendor(variant);
626 let value = crate::libtorch::detect::ld_library_path_value(
627 vendor,
628 &lib,
629 &crate::libtorch::detect::local_rocm_lib_dir(),
630 );
631 match std::env::var("LD_LIBRARY_PATH") {
632 Ok(cur) if !cur.is_empty() => format!("{value}:{cur}"),
633 _ => value,
634 }
635}
636
637fn attempt(
653 eff: &Effective,
654 active_libtorch: Option<&(PathBuf, String)>,
655 sig_cache: &mut Option<(u64, Option<String>)>,
656) -> Result<i32, Fail> {
657 let mut notes = Vec::new();
658 let prepared = prepare::prepare(&eff.prepare_spec(active_libtorch), &mut notes);
659 prepare::print_notes("join", ¬es);
660 let prepared = prepared?;
661
662 let (bin, bin_cwd) = match (&eff.bin, &prepared.bin) {
666 (BinSource::Build(_), Some(built)) => (built.bin.clone(), Some(built.cwd.clone())),
667 (BinSource::Given(path), None) => (PathBuf::from(path), None),
668 (kind, built) => {
672 return Err(Fail::Permanent(format!(
673 "internal: preparation and the resolved binary disagree \
674 ({}, built={})",
675 match kind {
676 BinSource::Given(_) => "a path was given",
677 BinSource::Build(_) => "a source was given",
678 },
679 built.is_some(),
680 )));
681 }
682 };
683
684 let args: &[String] = match &prepared.args {
690 Some(published) => {
691 if !eff.bin_args.is_empty() && published != &eff.bin_args {
692 eprintln!(
693 "{}",
694 style::dim(&format!(
695 "fdl join: the published run's arguments replace \
696 this box's ({} -> {})",
697 eff.bin_args.join(" "),
698 published.join(" "),
699 )),
700 );
701 }
702 published
703 }
704 None => &eff.bin_args,
705 };
706
707 let model_sig_hex = if eff.sig_probe {
717 match probe_recipe_digest(&bin, args) {
718 Some(digest) => match sig_cache {
719 Some((key, cached)) if *key == digest => cached.clone(),
720 _ => {
721 let sig =
722 model_sig_probe(&bin, bin_cwd.as_deref(), args, prepared.libtorch.as_ref());
723 *sig_cache = Some((digest, sig.clone()));
724 sig
725 }
726 },
727 None => model_sig_probe(&bin, bin_cwd.as_deref(), args, prepared.libtorch.as_ref()),
730 }
731 } else {
732 None
733 };
734
735 let mut tunnel: Option<Child> = None;
736 let dial: (String, u16) = match &eff.ssh {
737 Some(ssh) => {
738 let local_port = pick_local_port().map_err(Fail::Transient)?;
739 let argv =
740 build_tunnel_argv(ssh, local_port, &eff.controller_host, eff.controller_port);
741 eprintln!(
742 "fdl join: opening tunnel {} -> {}:{} (local port {local_port})",
743 ssh.target.as_deref().unwrap_or("?"),
744 eff.controller_host,
745 eff.controller_port,
746 );
747 let mut child = Command::new(&argv[0])
748 .args(&argv[1..])
749 .stdin(Stdio::null())
750 .spawn()
751 .map_err(|e| {
752 Fail::Permanent(format!("spawn ssh tunnel: {e}"))
755 })?;
756 if let Err(e) = wait_tunnel_ready(&mut child, local_port) {
761 let _ = child.kill();
762 let _ = child.wait();
763 return Err(Fail::Transient(e));
764 }
765 tunnel = Some(child);
766 ("127.0.0.1".to_string(), local_port)
767 }
768 None => (eff.controller_host.clone(), eff.controller_port),
769 };
770
771 let libtorch_label = prepared
775 .libtorch
776 .as_ref()
777 .map(|(_, l)| l.as_str())
778 .unwrap_or("");
779 let spec_hex = agent_spec_hex(
780 eff,
781 (&dial.0, dial.1),
782 libtorch_label,
783 &prepared,
784 model_sig_hex.as_deref(),
785 );
786
787 let mut cmd = Command::new(&bin);
788 cmd.args(args)
789 .env(ENV_AGENT_JSON, &spec_hex)
790 .env(crate::cluster::ENV_HOST_OVERRIDE, &eff.host)
793 .stdin(Stdio::null());
794 if let Some(cwd) = &bin_cwd {
795 cmd.current_dir(cwd);
796 }
797 if let Some((dir, variant)) = &prepared.libtorch {
798 cmd.env("LD_LIBRARY_PATH", child_ld_library_path(dir, variant));
799 }
800
801 let status = cmd
805 .status()
806 .map_err(|e| Fail::Permanent(format!("run {}: {e}", bin.display())));
807 if let Some(mut t) = tunnel.take() {
808 let _ = t.kill();
809 let _ = t.wait();
810 }
811 Ok(status?.code().unwrap_or(1))
812}
813
814fn probe_recipe_digest(bin: &Path, args: &[String]) -> Option<u64> {
823 use std::hash::{Hash, Hasher};
824 let meta = std::fs::metadata(bin).ok()?;
825 let mut h = std::collections::hash_map::DefaultHasher::new();
826 bin.hash(&mut h);
827 meta.len().hash(&mut h);
828 meta.modified()
829 .ok()?
830 .duration_since(std::time::UNIX_EPOCH)
831 .ok()?
832 .as_nanos()
833 .hash(&mut h);
834 args.hash(&mut h);
835 Some(h.finish())
836}
837
838const PROBE_TAIL_LINES: usize = 8;
841
842const ENV_MODEL_SIG_PROBE: &str = "FLODL_INTERNAL_MODEL_SIG_PROBE";
846
847const MODEL_SIG_LINE: &str = "flodl-model-sig: ";
850
851const MODEL_SIG_PROBE_TIMEOUT: Duration = Duration::from_secs(120);
856
857fn model_sig_probe(
866 bin: &Path,
867 cwd: Option<&Path>,
868 args: &[String],
869 libtorch: Option<&(PathBuf, String)>,
870) -> Option<String> {
871 eprintln!(
872 "{}",
873 style::dim(
874 "fdl join: probing the binary for its model signature \
875 (--no-sig-probe skips this)"
876 ),
877 );
878 let mut cmd = Command::new(bin);
879 cmd.args(args)
880 .env(ENV_MODEL_SIG_PROBE, "1")
881 .stdin(Stdio::null())
882 .stdout(Stdio::piped());
883 if let Some(dir) = cwd {
884 cmd.current_dir(dir);
885 }
886 if let Some((dir, variant)) = libtorch {
887 cmd.env("LD_LIBRARY_PATH", child_ld_library_path(dir, variant));
888 }
889 let mut child = match cmd.spawn() {
890 Ok(c) => c,
891 Err(e) => {
892 eprintln!(
893 "fdl join: model-sig probe could not run {}: {e}; joining \
894 without a signature",
895 bin.display(),
896 );
897 return None;
898 }
899 };
900 let stdout = child.stdout.take().expect("stdout was piped");
901 let reader = std::thread::spawn(move || {
902 use std::io::{BufRead, BufReader};
903 let mut sig = None;
904 let mut tail: std::collections::VecDeque<String> = std::collections::VecDeque::new();
908 for line in BufReader::new(stdout).lines() {
909 let Ok(line) = line else { break };
910 if let Some(rest) = line.strip_prefix(MODEL_SIG_LINE) {
911 sig = Some(rest.trim().to_string());
912 }
913 if tail.len() == PROBE_TAIL_LINES {
914 tail.pop_front();
915 }
916 tail.push_back(line);
917 }
918 (sig, tail)
919 });
920 let deadline = Instant::now() + MODEL_SIG_PROBE_TIMEOUT;
921 let status = loop {
922 match child.try_wait() {
923 Ok(Some(st)) => break Some(st),
924 Ok(None) if Instant::now() >= deadline => {
925 let _ = child.kill();
926 let _ = child.wait();
927 break None;
928 }
929 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
930 Err(_) => {
931 let _ = child.kill();
932 let _ = child.wait();
933 break None;
934 }
935 }
936 };
937 let (sig, tail) = reader.join().unwrap_or_default();
938 let sig = sig.filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()));
939 match (&status, &sig) {
940 (Some(st), Some(_)) if st.success() => sig,
941 (None, _) => {
942 eprintln!(
943 "fdl join: model-sig probe killed after {}s — a binary built \
944 against a flodl that predates the probe runs its whole main \
945 here; joining without a signature (`--no-sig-probe` or \
946 `join.sig_probe: false` silences this)",
947 MODEL_SIG_PROBE_TIMEOUT.as_secs(),
948 );
949 None
950 }
951 (Some(st), _) if !st.success() => {
952 eprintln!(
956 "fdl join: WARNING: the training binary exited with {} under \
957 the model-sig probe — rank children re-enter it with the \
958 same arguments after admission, so if this failure is real \
959 it takes the cohort's formation with it. Check: {} {}",
960 st.code().map_or("a signal".to_string(), |c| c.to_string()),
961 bin.display(),
962 args.join(" "),
963 );
964 None
965 }
966 _ => {
967 eprintln!(
976 "fdl join: model-sig probe exited 0 without printing a \
977 signature; joining without one — the formation-time check \
978 still applies. Either the binary predates the probe, or it \
979 failed before reaching the trainer (check its output above, \
980 and that this box can write wherever it writes).",
981 );
982 if !tail.is_empty() {
983 eprintln!("fdl join: last lines of the probe's output:");
984 for line in &tail {
985 eprintln!(" {line}");
986 }
987 }
988 None
989 }
990 }
991}
992
993fn pick_local_port() -> Result<u16, String> {
997 let listener =
998 TcpListener::bind("127.0.0.1:0").map_err(|e| format!("reserve local tunnel port: {e}"))?;
999 let port = listener
1000 .local_addr()
1001 .map_err(|e| format!("reserve local tunnel port: {e}"))?
1002 .port();
1003 Ok(port)
1004}
1005
1006fn build_tunnel_argv(
1011 ssh: &SshConfig,
1012 local_port: u16,
1013 controller_host: &str,
1014 controller_port: u16,
1015) -> Vec<String> {
1016 let mut argv: Vec<String> = vec!["ssh".into(), "-N".into(), "-T".into()];
1017 if let Some(warning) = crate::cluster::batchmode_override_warning(
1018 &ssh.options,
1019 ssh.target.as_deref().unwrap_or("?"),
1020 ) {
1021 eprintln!("{warning}");
1022 }
1023 for opt in &ssh.options {
1024 argv.push("-o".into());
1025 argv.push(opt.clone());
1026 }
1027 if let Some(port) = ssh.port {
1028 argv.push("-p".into());
1029 argv.push(port.to_string());
1030 }
1031 if let Some(user) = ssh.user.as_deref() {
1032 argv.push("-l".into());
1033 argv.push(user.to_string());
1034 }
1035 if let Some(id) = ssh.identity_file.as_deref() {
1036 argv.push("-i".into());
1037 argv.push(id.to_string());
1038 }
1039 argv.push("-o".into());
1045 argv.push("BatchMode=yes".into());
1046 argv.push("-o".into());
1047 argv.push("ExitOnForwardFailure=yes".into());
1048 argv.push("-o".into());
1049 argv.push("ServerAliveInterval=30".into());
1050 argv.push("-L".into());
1051 argv.push(format!(
1052 "127.0.0.1:{local_port}:{controller_host}:{controller_port}"
1053 ));
1054 argv.push(ssh.target.clone().unwrap_or_default());
1055 argv
1056}
1057
1058fn wait_tunnel_ready(child: &mut Child, local_port: u16) -> Result<(), String> {
1064 let deadline = Instant::now() + TUNNEL_READY_BUDGET;
1065 let addr = std::net::SocketAddr::from(([127, 0, 0, 1], local_port));
1066 loop {
1067 if let Ok(Some(status)) = child.try_wait() {
1068 return Err(format!(
1069 "ssh tunnel exited ({status}) before the forward came up — \
1070 see its output above (auth failure, or the remote refused \
1071 the forward)"
1072 ));
1073 }
1074 if let Ok(probe) = TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
1075 drop(probe);
1076 return Ok(());
1077 }
1078 if Instant::now() >= deadline {
1079 return Err(format!(
1080 "ssh tunnel did not come up within {}s (local port \
1081 {local_port} never accepted)",
1082 TUNNEL_READY_BUDGET.as_secs(),
1083 ));
1084 }
1085 std::thread::sleep(Duration::from_millis(200));
1086 }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092
1093 fn no_flags() -> JoinArgs {
1094 JoinArgs {
1095 controller: None,
1096 ssh: None,
1097 identity: None,
1098 token: None,
1099 bin: None,
1100 source: None,
1101 source_cwd: None,
1102 source_build: None,
1103 source_bin: None,
1104 libtorch: None,
1105 host: None,
1106 devices: None,
1107 persist: false,
1108 data_path: None,
1109 data_source: None,
1110 gpu_ram_share: None,
1111 no_sig_probe: false,
1112 }
1113 }
1114
1115 fn full_block() -> WorkerJoin {
1116 WorkerJoin {
1117 controller: Some("10.0.0.9:9000".into()),
1118 ssh: Some(SshConfig {
1119 target: Some("bastion".into()),
1120 port: Some(2222),
1121 user: Some("join-user".into()),
1122 identity_file: Some("/etc/flodl/join_key".into()),
1123 options: vec!["StrictHostKeyChecking=accept-new".into()],
1124 }),
1125 token: Some("aa".repeat(16)),
1126 bin: Some("target/release/train".into()),
1127 source: None,
1128 libtorch: Some("auto".into()),
1129 host: Some("worker-7".into()),
1130 devices: Some(vec![0, 1]),
1131 persist: true,
1132 args: vec!["--model".into(), "lenet".into()],
1133 data_path: Some("/flodl/data".into()),
1134 data_source: Some("sshfs://flodl@ctrl:/srv/data".into()),
1135 gpu_ram_share: Some(0.5),
1136 sig_probe: None,
1137 }
1138 }
1139
1140 fn source_block() -> WorkerJoin {
1142 WorkerJoin {
1143 source: Some(WorkerSource {
1144 from: "rsync://exa:/home/op/rdl".into(),
1145 cwd: Some("ddp-bench".into()),
1146 build: Some("cargo build --release --bin ddp-bench".into()),
1147 bin: Some("target/release/ddp-bench".into()),
1148 }),
1149 bin: None,
1150 ..full_block()
1151 }
1152 }
1153
1154 #[test]
1155 fn flags_win_over_the_config_block() {
1156 let cli = JoinArgs {
1157 controller: Some("exa".into()),
1158 ssh: Some("op@front:22".into()),
1159 identity: Some("/tmp/id".into()),
1160 token: Some("bb".repeat(16)),
1161 bin: Some("other/bin".into()),
1162 libtorch: Some("cu128".into()),
1163 host: Some("pascal".into()),
1164 devices: Some("2".into()),
1165 persist: false,
1166 data_path: Some("/mnt/corpus".into()),
1167 data_source: Some("sshfs://exa/mnt/corpus".into()),
1168 ..no_flags()
1169 };
1170 let tail: Vec<String> = vec!["--epochs".into(), "3".into()];
1171 let eff = resolve_effective(&cli, Some(&tail), Some(full_block()), "localbox").unwrap();
1172 assert_eq!(eff.controller_host, "exa");
1173 assert_eq!(eff.controller_port, DEFAULT_CONTROLLER_PORT);
1174 assert!(!eff.controller_defaulted);
1175 let ssh = eff.ssh.as_ref().unwrap();
1176 assert_eq!(ssh.target.as_deref(), Some("front"));
1177 assert_eq!(ssh.user.as_deref(), Some("op"));
1178 assert_eq!(ssh.port, Some(22));
1179 assert_eq!(ssh.identity_file.as_deref(), Some("/tmp/id"));
1181 assert_eq!(
1182 ssh.options,
1183 vec!["StrictHostKeyChecking=accept-new".to_string()]
1184 );
1185 assert_eq!(eff.token.as_deref(), Some("bb".repeat(16).as_str()));
1186 assert_eq!(eff.bin, BinSource::Given("other/bin".into()));
1187 assert_eq!(eff.libtorch_spec.as_deref(), Some("cu128"));
1188 assert_eq!(eff.host, "pascal");
1189 assert_eq!(eff.devices, Some(vec![2]));
1190 assert!(eff.persist);
1192 assert_eq!(eff.bin_args, vec!["--epochs".to_string(), "3".into()]);
1194 assert_eq!(eff.data_path.as_deref(), Some("/mnt/corpus"));
1195 assert_eq!(eff.data_source.as_deref(), Some("sshfs://exa/mnt/corpus"));
1196 }
1197
1198 #[test]
1199 fn block_fills_everything_the_flags_left_unset() {
1200 let eff = resolve_effective(&no_flags(), None, Some(full_block()), "localbox").unwrap();
1201 assert_eq!(eff.controller_host, "10.0.0.9");
1202 assert_eq!(eff.controller_port, 9000);
1203 let ssh = eff.ssh.as_ref().unwrap();
1204 assert_eq!(ssh.target.as_deref(), Some("bastion"));
1205 assert_eq!(ssh.identity_file.as_deref(), Some("/etc/flodl/join_key"));
1206 assert_eq!(eff.bin, BinSource::Given("target/release/train".into()));
1207 assert_eq!(eff.libtorch_spec.as_deref(), Some("auto"));
1208 assert_eq!(eff.host, "worker-7");
1209 assert_eq!(eff.devices, Some(vec![0, 1]));
1210 assert!(eff.persist);
1211 assert_eq!(eff.bin_args, vec!["--model".to_string(), "lenet".into()]);
1212 assert_eq!(eff.data_path.as_deref(), Some("/flodl/data"));
1213 assert_eq!(
1214 eff.data_source.as_deref(),
1215 Some("sshfs://flodl@ctrl:/srv/data"),
1216 );
1217 let spec = eff.prepare_spec(None);
1221 assert_eq!(
1222 spec.data.ssh.and_then(|s| s.identity_file.as_deref()),
1223 Some("/etc/flodl/join_key"),
1224 );
1225 }
1226
1227 #[test]
1228 fn a_source_block_becomes_a_source_spec_carrying_the_same_key() {
1229 let eff = resolve_effective(&no_flags(), None, Some(source_block()), "localbox").unwrap();
1230 let spec = eff.prepare_spec(None);
1231 let source = spec.source.expect("a source block yields a source spec");
1232 assert_eq!(source.from, "rsync://exa:/home/op/rdl");
1233 assert_eq!(source.cwd, Some("ddp-bench"));
1234 assert_eq!(source.bin, Some("target/release/ddp-bench"));
1235 assert_eq!(
1237 source.ssh.and_then(|s| s.identity_file.as_deref()),
1238 Some("/etc/flodl/join_key"),
1239 );
1240 }
1241
1242 #[test]
1243 fn naming_both_a_binary_and_a_source_is_a_loud_error() {
1244 let block = WorkerJoin {
1247 bin: Some("target/release/train".into()),
1248 ..source_block()
1249 };
1250 let err = resolve_effective(&no_flags(), None, Some(block), "x").unwrap_err();
1251 assert!(err.contains("both name"), "got: {err}");
1252 }
1253
1254 #[test]
1255 fn a_source_flag_keeps_the_blocks_other_source_fields() {
1256 let cli = JoinArgs {
1259 source: Some("file:///mnt/rdl".into()),
1260 ..no_flags()
1261 };
1262 let eff = resolve_effective(&cli, None, Some(source_block()), "x").unwrap();
1263 assert_eq!(
1264 eff.bin,
1265 BinSource::Build(WorkerSource {
1266 from: "file:///mnt/rdl".into(),
1267 cwd: Some("ddp-bench".into()),
1268 build: Some("cargo build --release --bin ddp-bench".into()),
1269 bin: Some("target/release/ddp-bench".into()),
1270 }),
1271 );
1272 }
1273
1274 #[test]
1275 fn a_source_with_no_artifact_is_legal_because_a_manifest_may_name_it() {
1276 let cli = JoinArgs {
1280 source: Some("file:///mnt/rdl".into()),
1281 ..no_flags()
1282 };
1283 let eff = resolve_effective(&cli, None, None, "x").unwrap();
1284 assert_eq!(
1285 eff.bin,
1286 BinSource::Build(WorkerSource {
1287 from: "file:///mnt/rdl".into(),
1288 cwd: None,
1289 build: None,
1290 bin: None,
1291 }),
1292 );
1293 }
1294
1295 #[test]
1296 fn a_source_detail_flag_with_no_source_is_a_loud_error() {
1297 let cli = JoinArgs {
1301 bin: Some("t/bin".into()),
1302 source_cwd: Some("ddp-bench".into()),
1303 ..no_flags()
1304 };
1305 let err = resolve_effective(&cli, None, None, "x").unwrap_err();
1306 assert!(err.contains("--source-cwd"), "got: {err}");
1307 assert!(err.contains("no source"), "got: {err}");
1308 }
1309
1310 #[test]
1311 fn defaults_are_loopback_hostname_and_all_devices() {
1312 let cli = JoinArgs {
1313 bin: Some("t/bin".into()),
1314 ..no_flags()
1315 };
1316 let eff = resolve_effective(&cli, None, None, "localbox").unwrap();
1317 assert_eq!(eff.controller_host, "127.0.0.1");
1318 assert_eq!(eff.controller_port, DEFAULT_CONTROLLER_PORT);
1319 assert!(eff.controller_defaulted);
1320 assert!(eff.ssh.is_none());
1321 assert!(eff.token.is_none());
1322 assert_eq!(eff.host, "localbox");
1323 assert_eq!(eff.devices, None);
1324 assert!(!eff.persist);
1325 assert!(eff.bin_args.is_empty());
1326 assert!(eff.data_path.is_none());
1329 assert!(eff.data_source.is_none());
1330 }
1331
1332 #[test]
1333 fn an_explicit_empty_tail_clears_the_block_args() {
1334 let eff =
1337 resolve_effective(&no_flags(), Some(&[]), Some(full_block()), "localbox").unwrap();
1338 assert!(eff.bin_args.is_empty());
1339 }
1340
1341 #[test]
1342 fn identity_without_an_ssh_hop_is_a_loud_error() {
1343 let cli = JoinArgs {
1344 identity: Some("/tmp/id".into()),
1345 bin: Some("t/bin".into()),
1346 ..no_flags()
1347 };
1348 let err = resolve_effective(&cli, None, None, "x").unwrap_err();
1349 assert!(err.contains("ssh hop"), "got: {err}");
1350 }
1351
1352 #[test]
1353 fn missing_bin_is_a_loud_error() {
1354 let err = resolve_effective(&no_flags(), None, None, "x").unwrap_err();
1355 assert!(err.contains("--bin"), "got: {err}");
1356 assert!(err.contains("join.bin"), "got: {err}");
1357 }
1358
1359 #[test]
1360 fn ssh_implies_the_loopback_controller_without_a_note() {
1361 let cli = JoinArgs {
1362 ssh: Some("join@ctrl".into()),
1363 bin: Some("t/bin".into()),
1364 ..no_flags()
1365 };
1366 let eff = resolve_effective(&cli, None, None, "x").unwrap();
1367 assert_eq!(eff.controller_host, "127.0.0.1");
1368 assert_eq!(eff.controller_port, DEFAULT_CONTROLLER_PORT);
1369 assert!(
1370 !eff.controller_defaulted,
1371 "tunnel loopback is the convention"
1372 );
1373 }
1374
1375 #[test]
1376 fn block_ssh_without_target_is_a_loud_error() {
1377 let block = WorkerJoin {
1378 ssh: Some(SshConfig::default()),
1379 bin: Some("t/bin".into()),
1380 ..WorkerJoin::default()
1381 };
1382 let err = resolve_effective(&no_flags(), None, Some(block), "x").unwrap_err();
1383 assert!(err.contains("target"), "got: {err}");
1384 }
1385
1386 #[test]
1387 fn spec_parsers_cover_their_shapes() {
1388 assert_eq!(
1389 parse_host_port("exa").unwrap(),
1390 ("exa".to_string(), DEFAULT_CONTROLLER_PORT),
1391 );
1392 assert_eq!(
1393 parse_host_port("exa:9000").unwrap(),
1394 ("exa".to_string(), 9000)
1395 );
1396 assert!(parse_host_port(":9000").is_err());
1397 assert!(parse_host_port("exa:banana").is_err());
1398
1399 let ssh = parse_ssh_spec("join@ctrl:2222").unwrap();
1400 assert_eq!(ssh.target.as_deref(), Some("ctrl"));
1401 assert_eq!(ssh.user.as_deref(), Some("join"));
1402 assert_eq!(ssh.port, Some(2222));
1403 let bare = parse_ssh_spec("ctrl").unwrap();
1404 assert_eq!(bare.target.as_deref(), Some("ctrl"));
1405 assert_eq!(bare.user, None);
1406 assert_eq!(bare.port, None);
1407 assert!(parse_ssh_spec("@ctrl").is_err());
1408 assert!(parse_ssh_spec("join@").is_err());
1409 assert!(parse_ssh_spec("ctrl:pear").is_err());
1410
1411 assert_eq!(parse_devices("0,1").unwrap(), Some(vec![0, 1]));
1412 assert_eq!(parse_devices(" 2 ").unwrap(), Some(vec![2]));
1413 assert_eq!(parse_devices("all").unwrap(), None);
1414 assert!(parse_devices("0,x").is_err());
1415 }
1416
1417 #[test]
1423 fn probe_recipe_digest_binds_binary_identity_and_args() {
1424 let dir = std::env::temp_dir().join(format!("fdl-sig-digest-test-{}", std::process::id()));
1425 std::fs::create_dir_all(&dir).unwrap();
1426 let bin = dir.join("train");
1427 std::fs::write(&bin, b"v1").unwrap();
1428 let args = vec!["--model".to_string(), "lenet".to_string()];
1429 let base = probe_recipe_digest(&bin, &args).unwrap();
1430 assert_eq!(probe_recipe_digest(&bin, &args).unwrap(), base);
1431 assert_ne!(
1432 probe_recipe_digest(&bin, &["--model".to_string(), "resnet".to_string()]).unwrap(),
1433 base,
1434 "args are part of the recipe (a re-publish must re-probe)"
1435 );
1436 std::fs::write(&bin, b"v2 longer").unwrap();
1438 assert_ne!(
1439 probe_recipe_digest(&bin, &args).unwrap(),
1440 base,
1441 "a rebuilt binary must re-probe"
1442 );
1443 assert_eq!(probe_recipe_digest(&dir.join("absent"), &args), None);
1444 let _ = std::fs::remove_dir_all(&dir);
1445 }
1446
1447 #[cfg(unix)]
1455 #[test]
1456 fn model_sig_probe_parses_the_line_and_absorbs_failures() {
1457 let sh = PathBuf::from("/bin/sh");
1466 let run = |body: String| model_sig_probe(&sh, None, &["-c".to_string(), body], None);
1467 let sig = "ab".repeat(32);
1468 assert_eq!(
1469 run(format!("echo main noise; echo '{MODEL_SIG_LINE}{sig}'")),
1470 Some(sig),
1471 );
1472 assert_eq!(run("exit 0".to_string()), None);
1473 assert_eq!(run("exit 3".to_string()), None);
1474 assert_eq!(run(format!("echo '{MODEL_SIG_LINE}not-hex-at-all'")), None,);
1475 }
1476
1477 #[test]
1480 fn agent_spec_shape_is_the_wire_contract() {
1481 let cli = JoinArgs {
1482 token: Some("ab".repeat(16)),
1483 bin: Some("t/bin".into()),
1484 host: Some("pascal".into()),
1485 devices: Some("0,1".into()),
1486 gpu_ram_share: Some(0.5),
1487 ..no_flags()
1488 };
1489 let eff = resolve_effective(&cli, None, None, "x").unwrap();
1490 let prepared = Prepared {
1491 data_path: Some(PathBuf::from("/flodl/data")),
1492 run_id: Some("a1b2c3d4e5f60718".to_string()),
1493 ..Prepared::default()
1494 };
1495 let hex = agent_spec_hex(
1496 &eff,
1497 ("127.0.0.1", 40123),
1498 "builds/sm61-sm120",
1499 &prepared,
1500 Some(&"cd".repeat(32)),
1501 );
1502 let bytes: Vec<u8> = (0..hex.len())
1503 .step_by(2)
1504 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
1505 .collect();
1506 let spec: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1507 assert_eq!(spec["host"], "pascal");
1508 assert_eq!(spec["controller_host"], "127.0.0.1");
1509 assert_eq!(spec["controller_port"], 40123);
1510 assert_eq!(spec["salt_hex"], "ab".repeat(16));
1511 assert_eq!(spec["local_devices"], serde_json::json!([0, 1]));
1512 assert_eq!(spec["libtorch"], "builds/sm61-sm120");
1513 assert_eq!(spec["data_path"], "/flodl/data");
1514 assert_eq!(spec["run_id"], "a1b2c3d4e5f60718");
1515 assert_eq!(spec["gpu_ram_share"], 0.5);
1516 assert_eq!(spec["model_sig_hex"], "cd".repeat(32));
1517 let open = {
1520 let cli = JoinArgs {
1521 bin: Some("t/bin".into()),
1522 ..no_flags()
1523 };
1524 let eff = resolve_effective(&cli, None, None, "cloud-1").unwrap();
1525 agent_spec_hex(&eff, ("10.0.0.1", 1337), "", &Prepared::default(), None)
1526 };
1527 let bytes: Vec<u8> = (0..open.len())
1528 .step_by(2)
1529 .map(|i| u8::from_str_radix(&open[i..i + 2], 16).unwrap())
1530 .collect();
1531 let spec: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1532 assert!(spec.get("salt_hex").is_none());
1533 assert!(spec.get("local_devices").is_none());
1534 assert!(spec.get("dataset_sig_hex").is_none());
1535 assert!(spec.get("data_path").is_none());
1538 assert!(spec.get("run_id").is_none());
1541 assert!(spec.get("gpu_ram_share").is_none());
1544 }
1545
1546 #[test]
1547 fn tunnel_argv_orders_user_options_before_the_defaults() {
1548 let ssh = SshConfig {
1549 target: Some("ctrl".into()),
1550 port: Some(2222),
1551 user: Some("join-user".into()),
1552 identity_file: Some("/etc/flodl/join_key".into()),
1553 options: vec!["ServerAliveInterval=5".into()],
1554 };
1555 let argv = build_tunnel_argv(&ssh, 40123, "127.0.0.1", 1337);
1556 assert_eq!(argv[0], "ssh");
1557 assert!(argv.contains(&"-N".to_string()));
1558 assert!(argv.contains(&"BatchMode=yes".to_string()));
1559 assert!(argv.contains(&"ExitOnForwardFailure=yes".to_string()));
1560 let user_pos = argv
1563 .iter()
1564 .position(|a| a == "ServerAliveInterval=5")
1565 .unwrap();
1566 let default_pos = argv
1567 .iter()
1568 .position(|a| a == "ServerAliveInterval=30")
1569 .unwrap();
1570 assert!(user_pos < default_pos);
1571 assert!(argv.contains(&"127.0.0.1:40123:127.0.0.1:1337".to_string()));
1572 assert_eq!(argv.last().map(String::as_str), Some("ctrl"));
1573 let p = argv.iter().position(|a| a == "-p").unwrap();
1574 assert_eq!(argv[p + 1], "2222");
1575 let l = argv.iter().position(|a| a == "-l").unwrap();
1576 assert_eq!(argv[l + 1], "join-user");
1577 let i = argv.iter().position(|a| a == "-i").unwrap();
1578 assert_eq!(argv[i + 1], "/etc/flodl/join_key");
1579 }
1580
1581 #[test]
1582 fn wait_tunnel_ready_sees_a_live_listener_and_a_dead_child() {
1583 let mut dead = Command::new("true").spawn().unwrap();
1585 std::thread::sleep(Duration::from_millis(50));
1586 let err = wait_tunnel_ready(&mut dead, 1).unwrap_err();
1587 assert!(err.contains("before the forward came up"), "got: {err}");
1588
1589 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1591 let port = listener.local_addr().unwrap().port();
1592 let mut slow = Command::new("sleep").arg("5").spawn().unwrap();
1593 assert!(wait_tunnel_ready(&mut slow, port).is_ok());
1594 let _ = slow.kill();
1595 let _ = slow.wait();
1596 }
1597
1598 #[test]
1599 fn hex_encode_is_lowercase_bytewise() {
1600 assert_eq!(hex_encode(b"\x00\xff\x10"), "00ff10");
1601 assert_eq!(hex_encode(b"{}"), "7b7d");
1602 }
1603}