sync.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. use crate::config::{Cluster, Config, Node};
  2. use crate::ssh::{run_ssh, run_ssh_long_running};
  3. use anyhow::Result;
  4. use tracing::info;
  5. use super::shq;
  6. /// Get the Docker image digest (short ID) from a node.
  7. async fn get_image_digest(config: &Config, node: &Node, image: &str) -> Result<Option<String>> {
  8. // `docker image ls` does not accept digest refs (`repo@sha256:...`) —
  9. // use `docker image inspect` for those; it also returns the image ID.
  10. // `|| true`: inspect exits 1 when the ref is absent (a peer that simply
  11. // doesn't have it yet) — that is "no digest", not an ssh failure.
  12. let cmd = if image.contains('@') {
  13. format!(
  14. "docker image inspect --format '{{{{.Id}}}}' {} 2>/dev/null || true",
  15. shq(image)
  16. )
  17. } else {
  18. format!("docker image ls --format '{{{{.ID}}}}' {}", shq(image))
  19. };
  20. let out = run_ssh(config, node, cmd, false).await?;
  21. let digest = out.trim().to_string();
  22. if digest.is_empty() {
  23. Ok(None)
  24. } else {
  25. Ok(Some(digest))
  26. }
  27. }
  28. /// Probe one node for the image: digest + creation timestamp (the latter
  29. /// only when present — two serial round-trips inside, but nodes are
  30. /// probed in parallel by [`probe_all`]).
  31. async fn probe_node(
  32. config: &Config,
  33. node: &Node,
  34. image: &str,
  35. ) -> Result<(Node, Option<String>, Option<String>)> {
  36. let node = node.clone();
  37. let digest = get_image_digest(config, &node, image).await?;
  38. let created = if digest.is_some() {
  39. run_ssh(
  40. config,
  41. &node,
  42. format!(
  43. "docker image inspect --format '{{{{.Created}}}}' {} 2>/dev/null",
  44. shq(image)
  45. ),
  46. false,
  47. )
  48. .await
  49. .ok()
  50. .map(|c| c.trim().to_string())
  51. } else {
  52. None
  53. };
  54. Ok((node, digest, created))
  55. }
  56. /// Probe every cluster node for the image digest/creation time in parallel.
  57. async fn probe_all(
  58. config: &Config,
  59. cluster: &Cluster,
  60. image: &str,
  61. ) -> Result<Vec<(Node, Option<String>, Option<String>)>> {
  62. let mut handles = Vec::new();
  63. for node in &cluster.nodes {
  64. let config = config.clone();
  65. let node = (*node).clone();
  66. let image = image.to_string();
  67. handles.push(crate::locale::spawn_with_locale(async move {
  68. probe_node(&config, &node, &image).await
  69. }));
  70. }
  71. let mut probed = Vec::new();
  72. for h in futures::future::join_all(handles).await {
  73. probed.push(h??); // join error first, then the probe's own ssh error
  74. }
  75. Ok(probed)
  76. }
  77. /// Ensure a prebuilt (externally built) image is present on every node of
  78. /// the launch set BEFORE `docker run`: sync it node-to-node from a peer that
  79. /// already has it, or fail loudly when no node has it.
  80. ///
  81. /// Without this check a missing image makes `docker run` silently pull it
  82. /// from the registry over the internet; the SSH timeout then kills the pull
  83. /// while the serve still reports "started" and waits out the readiness
  84. /// timeout for a container that never loads.
  85. pub async fn ensure_prebuilt_image(config: &Config, cluster: &Cluster, image: &str) -> Result<()> {
  86. sync_image_to_nodes(config, cluster, image, None).await.map_err(|e| {
  87. anyhow::anyhow!(
  88. "image '{}' not available on the launch nodes: {e} \u{2014} pull it on one node first (or build it with --build-node)",
  89. image
  90. )
  91. })
  92. }
  93. /// Sync a Docker image across cluster nodes — one `docker save` on the
  94. /// source, then a PARALLEL fan-out of `ssh <target> docker load < tar` to
  95. /// every target that is missing or holds a different digest.
  96. ///
  97. /// Why not one save per target (the old shape): with N peers the source
  98. /// re-serialized the whole image N times and the transfers ran strictly
  99. /// serially — N × (save + load) wall time for one image. The fabric
  100. /// carries multiple streams at full rate, so one save + N parallel loads
  101. /// costs ≈ save + max(load), roughly N× faster for a 4-node set.
  102. ///
  103. /// Data path: node-to-node, never through the Mac (mac→node is ~32 MB/s vs
  104. /// ~547 MB/s per stream on the 200G fabric). The inner hop prefers the
  105. /// target's fabric IP (ib_ip); if that fails it falls back to the 10G mgmt
  106. /// IP with a warning (the per-target known_hosts covers both hops and lives
  107. /// until the target task ends — the old per-pipe `rm` deleted it before the
  108. /// fallback attempt).
  109. ///
  110. /// Security of the inner hop: the target's full public key is fetched over
  111. /// a russh connection that enforces the pinned fingerprint, written to a
  112. /// per-target `known_hosts` on the source node, and passed to the inner
  113. /// `ssh` with `StrictHostKeyChecking=yes` — an impersonator at the target
  114. /// IP is rejected before auth. The file is removed after the target task.
  115. pub async fn sync_image_to_nodes(
  116. config: &Config,
  117. cluster: &Cluster,
  118. image: &str,
  119. explicit_source: Option<&str>,
  120. ) -> Result<()> {
  121. let probed = probe_all(config, cluster, image).await?;
  122. let source_idx = if let Some(src_name) = explicit_source {
  123. probed
  124. .iter()
  125. .position(|(n, _, _)| n.name == src_name)
  126. .ok_or_else(|| {
  127. anyhow::anyhow!("sync-image-source '{}' not found in cluster", src_name)
  128. })?
  129. } else {
  130. let mut indexed: Vec<usize> = probed
  131. .iter()
  132. .enumerate()
  133. .filter(|(_, (_, d, _))| d.is_some())
  134. .map(|(i, _)| i)
  135. .collect();
  136. indexed.sort_by(|&a, &b| {
  137. probed[b]
  138. .2
  139. .as_deref()
  140. .unwrap_or("")
  141. .cmp(probed[a].2.as_deref().unwrap_or(""))
  142. });
  143. *indexed
  144. .first()
  145. .ok_or_else(|| anyhow::anyhow!("Image '{}' not found on any node", image))?
  146. };
  147. let (src_node, src_digest_opt, _) = &probed[source_idx];
  148. let source_digest = src_digest_opt
  149. .as_ref()
  150. .ok_or_else(|| anyhow::anyhow!("source node '{}' lost the image", src_node.name))?;
  151. // WHY this node won the source selection: an explicit source (a
  152. // just-built image or --sync-image-source) is authoritative, whereas
  153. // the heuristic source is simply the node with the newest creation
  154. // timestamp — a peer could differ and this is the tie-break that was
  155. // overwriting fresh builds.
  156. let reason = if explicit_source.is_some() {
  157. "authoritative image"
  158. } else {
  159. "newest image (creation timestamp tie-break)"
  160. };
  161. info!(
  162. "Syncing image '{}' FROM '{}' ({}) ...",
  163. image, src_node.name, reason
  164. );
  165. // Targets: peers with no image or a different digest.
  166. let mut targets: Vec<Node> = Vec::new();
  167. for (node, digest, _) in &probed {
  168. if node.name == src_node.name {
  169. continue;
  170. }
  171. if digest.as_deref() == Some(source_digest.as_str()) {
  172. info!(
  173. "{}",
  174. fluent_i18n::t!("sync-digest-match", { "name" => &node.name })
  175. );
  176. continue;
  177. }
  178. targets.push(node.clone());
  179. }
  180. if targets.is_empty() {
  181. return Ok(());
  182. }
  183. // ONE save on the source: /var/tmp on the root NVMe (docker save writes
  184. // owned by the invoking user), filename-safe image name.
  185. let tar = format!("/var/tmp/sm121_sync_{}.tar", safe_name(image));
  186. info!(
  187. "Saving '{}' once on '{}' ({} peer(s) in parallel) ...",
  188. image,
  189. src_node.name,
  190. targets.len()
  191. );
  192. let save_cmd = format!(
  193. "rm -f {tar} && docker save -o {tar} {image} || {{ rm -f {tar}; exit 1; }}",
  194. tar = shq(&tar),
  195. image = shq(image),
  196. );
  197. if let Err(e) = run_ssh_long_running(config, src_node, save_cmd, false).await {
  198. run_ssh(config, src_node, format!("rm -f {}", shq(&tar)), false)
  199. .await
  200. .ok();
  201. anyhow::bail!("docker save on '{}' failed: {e}", src_node.name);
  202. }
  203. // Parallel fan-out: one task per target (fabric IP first, mgmt IP
  204. // fallback, digest verification, known_hosts cleanup inside the task).
  205. let mut handles = Vec::new();
  206. for target in &targets {
  207. let config = config.clone();
  208. let src_node = src_node.clone();
  209. let target = target.clone();
  210. let image = image.to_string();
  211. let tar = tar.clone();
  212. let expected = source_digest.clone();
  213. let key = node_key_path(&config);
  214. let user = config.ssh_user.clone();
  215. handles.push(crate::locale::spawn_with_locale(async move {
  216. info!(
  217. "{}",
  218. fluent_i18n::t!("sync-syncing", { "name" => &target.name, "ip" => &target.ssh_ip })
  219. );
  220. let name = target.name.clone();
  221. let pubkey = match crate::ssh::fetch_host_pubkey(&config, &target).await {
  222. Ok(k) => k,
  223. Err(e) => return (name, Some(format!("host key fetch: {e}"))),
  224. };
  225. let known_hosts = format!("/tmp/sm121_known_hosts_{}", target.name);
  226. let mut kh_ips = vec![target.ssh_ip.clone()];
  227. if let Some(ib) = &target.ib_ip {
  228. kh_ips.push(ib.clone());
  229. }
  230. let entry = kh_ips
  231. .iter()
  232. .map(|ip| format!("{} {}", ip, pubkey))
  233. .collect::<Vec<_>>()
  234. .join("\n")
  235. + "\n";
  236. let write_known = format!(
  237. "printf '%s' {} > {} && chmod 600 {}",
  238. shq(&entry),
  239. shq(&known_hosts),
  240. shq(&known_hosts),
  241. );
  242. if let Err(e) = run_ssh(&config, &src_node, write_known, false).await {
  243. return (name, Some(format!("known_hosts write: {e}")));
  244. }
  245. let attempts: Vec<String> = match &target.ib_ip {
  246. Some(ib) => vec![ib.clone(), target.ssh_ip.clone()],
  247. None => vec![target.ssh_ip.clone()],
  248. };
  249. let mut last_err: Option<String> = None;
  250. let mut synced = false;
  251. for (idx, ip) in attempts.iter().enumerate() {
  252. let pipe = format!(
  253. "ssh -o StrictHostKeyChecking=yes -o HashKnownHosts=no -o UserKnownHostsFile={kh} -i {key} {user}@{ip} docker load < {tar}; exit $?",
  254. kh = shq(&known_hosts),
  255. key = key,
  256. user = shq(&user),
  257. ip = shq(ip),
  258. tar = shq(&tar),
  259. );
  260. match run_ssh_long_running(&config, &src_node, pipe, false).await {
  261. Ok(_) => {
  262. // A digest-only ref (`repo@sha256:...`) loses its
  263. // RepoDigest association through save/load — the tar
  264. // carries no tags, so the loaded image is untagged
  265. // and `docker run <ref>` fails with "No such image"
  266. // on the peer. Re-attach it with a pull-by-digest:
  267. // all layers are local, this fetches nothing but the
  268. // manifest (and pins the association).
  269. if image.contains('@') {
  270. let pull = format!("docker pull {}", shq(&image));
  271. if let Err(e) = run_ssh(&config, &target, pull, false).await {
  272. last_err = Some(format!("pull-by-digest re-attach: {e}"));
  273. continue;
  274. }
  275. }
  276. let verify = get_image_digest(&config, &target, &image).await;
  277. match verify {
  278. Ok(v) if v.as_deref() == Some(expected.as_str()) => {
  279. synced = true;
  280. break;
  281. }
  282. Ok(v) => {
  283. // a digest mismatch is not a connectivity
  284. // problem — the other IP won't help, bail out
  285. last_err = Some(format!(
  286. "digest mismatch (source={expected}, target={v:?})"
  287. ));
  288. break;
  289. }
  290. Err(e) => last_err = Some(format!("digest verify: {e}")),
  291. }
  292. }
  293. Err(e) => {
  294. if idx == 0 {
  295. tracing::warn!(
  296. "{}",
  297. fluent_i18n::t!("sync-fabric-fallback", {
  298. "name" => &target.name,
  299. "err" => e.to_string(),
  300. })
  301. );
  302. }
  303. last_err = Some(e.to_string());
  304. }
  305. }
  306. }
  307. // known_hosts matters only for this target — keep it until the
  308. // last attempt has run, then drop it on the source node.
  309. run_ssh(
  310. &config,
  311. &src_node,
  312. format!("rm -f {}", shq(&known_hosts)),
  313. false,
  314. )
  315. .await
  316. .ok();
  317. if synced {
  318. info!(
  319. "{}",
  320. fluent_i18n::t!("sync-synced", { "name" => &target.name })
  321. );
  322. (name, None)
  323. } else {
  324. (name, Some(last_err.unwrap_or_default()))
  325. }
  326. }));
  327. }
  328. let mut failed: Vec<String> = Vec::new();
  329. for h in futures::future::join_all(handles).await {
  330. match h {
  331. Ok((_, None)) => {}
  332. Ok((name, Some(e))) => failed.push(format!("{name}: {e}")),
  333. Err(e) => failed.push(format!("task: {e}")),
  334. }
  335. }
  336. run_ssh(config, src_node, format!("rm -f {}", shq(&tar)), false)
  337. .await
  338. .ok();
  339. if failed.is_empty() {
  340. Ok(())
  341. } else {
  342. anyhow::bail!("Image sync failed on: {}", failed.join("; "))
  343. }
  344. }
  345. /// Identity path for an INNER ssh hop that runs ON a node. The configured
  346. /// key path belongs to the Mac (`/Users/kilork/...`), which nodes do not
  347. /// have — nodes hold the shared key at `~/.ssh/<basename>` (dgx5-8 lack the
  348. /// `~/.ssh/config` IdentityFile fallback dgx1-4 have, so there the mac path
  349. /// failed with exit 255 / "Permission denied", 2026-09-29). The basename is
  350. /// what the inner hop must reference; the shell expands `~` itself.
  351. fn node_key_path(config: &Config) -> String {
  352. config
  353. .ssh_key_path
  354. .file_name()
  355. .map(|f| format!("~/.ssh/{}", f.to_string_lossy()))
  356. .filter(|f| f.len() > "~/.ssh/".len())
  357. .unwrap_or_else(|| config.ssh_key_path.display().to_string())
  358. }
  359. /// Filename-safe image name for the one-shot save tarball on the source.
  360. fn safe_name(image: &str) -> String {
  361. image
  362. .chars()
  363. .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
  364. .collect()
  365. }
  366. /// `sm121 sync-image` handler: distribute a recipe's image across the
  367. /// cluster without starting a serve (pre-warm before bench/serve).
  368. pub async fn handle_sync_image(
  369. config: &Config,
  370. recipe_name: &str,
  371. cluster_name: &str,
  372. source: Option<&str>,
  373. dry_run: bool,
  374. ) -> Result<()> {
  375. let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  376. let cluster = crate::config::select_cluster_by_name(config, cluster_name)?;
  377. let image = recipe.docker_image.clone();
  378. if dry_run {
  379. // No SSH in dry-run: print the plan only.
  380. println!(
  381. "sync-image: {} over {} node(s) of '{}'",
  382. image,
  383. cluster.nodes.len(),
  384. cluster.name
  385. );
  386. println!(
  387. "sync-image: ssh <source>: rm -f /var/tmp/sm121_sync_<image>.tar && docker save -o /var/tmp/sm121_sync_<image>.tar {image}"
  388. );
  389. for n in &cluster.nodes {
  390. let hop = n.ib_ip.as_deref().unwrap_or(&n.ssh_ip);
  391. println!(
  392. "sync-image: {} <- ssh {}@{} docker load < /var/tmp/sm121_sync_<image>.tar (parallel)",
  393. n.name, config.ssh_user, hop
  394. );
  395. }
  396. return Ok(());
  397. }
  398. sync_image_to_nodes(config, &cluster, &image, source).await
  399. }
  400. #[cfg(test)]
  401. mod tests {
  402. use super::safe_name;
  403. #[test]
  404. fn safe_image_name_is_filename_safe() {
  405. let safe = safe_name("eugr/spark-vllm-b12x:latest@sha256:abc123");
  406. assert!(!safe.contains('/') && !safe.contains(':') && !safe.contains('@'));
  407. assert!(!safe.is_empty());
  408. }
  409. }