stop.rs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. //! Builder for the `sm121 stop` per-node remote command.
  2. //!
  3. //! `docker stop` only returns once containers reach the `Exited` state; the
  4. //! `--rm` removal and the GPU/CUDA-context teardown can lag behind it.
  5. //! Re-serving on the same node in that window makes the new container race
  6. //! the dying one (immediate worker death). By default the command therefore
  7. //! verifies — polls up to 60 s — that no labeled container is running before
  8. //! reporting success; `--no-wait` restores the legacy fire-and-forget behavior.
  9. use super::shq;
  10. use crate::config::{Cluster, Config, Node};
  11. use tracing::info;
  12. /// Builds the per-node remote stop command.
  13. ///
  14. /// `wait = true` (default): after `docker stop`, poll until `docker ps` shows
  15. /// no container with the `sm121.recipe` / `sm121.cluster` labels in ANY state
  16. /// (running or exited-but-not-yet-removed) for 30 s, then explicitly `rm` the
  17. /// stragglers and poll running-only for another 30 s. Reporting success while
  18. /// any labeled container still existed is what made an immediately following
  19. /// `serve` see the node as `occupied` and bail out.
  20. pub fn remote_command(recipe_name: &str, cluster_name: &str, rank: u32, wait: bool) -> String {
  21. let filter = format!(
  22. "--filter {} --filter {}",
  23. shq(&format!("label=sm121.recipe={}", recipe_name)),
  24. shq(&format!("label=sm121.cluster={}", cluster_name)),
  25. );
  26. let wait_block = if wait {
  27. "\
  28. for _ in $(seq 1 30); do \
  29. n=$(docker ps -aq {filter}); \
  30. [ -z \"$n\" ] && break; \
  31. sleep 1; \
  32. done; \
  33. docker ps -aq {filter} | xargs -r docker rm >/dev/null 2>&1; \
  34. for _ in $(seq 1 30); do \
  35. n=$(docker ps -q {filter}); \
  36. [ -z \"$n\" ] && break; \
  37. sleep 1; \
  38. done; \
  39. n=$(docker ps -aq {filter}); \
  40. if [ -n \"$n\" ]; then \
  41. echo \"sm121 stop: containers still present after wait: $n\" >&2; \
  42. exit 1; \
  43. fi; \
  44. n=$(docker ps -q {filter}); \
  45. if [ -n \"$n\" ]; then \
  46. echo \"sm121 stop: containers still running after wait: $n\" >&2; \
  47. exit 1; \
  48. fi; "
  49. .to_string()
  50. } else {
  51. String::new()
  52. };
  53. format!(
  54. "docker ps -q {filter} | xargs -r docker stop; rm -rf {tmp} 2>/dev/null; {wait_block}true",
  55. tmp = shq(&format!("/tmp/sm121_{}_{}", recipe_name, rank)),
  56. wait_block = wait_block,
  57. )
  58. }
  59. /// Find the cluster containing all named nodes (for `--node` without
  60. /// `--cluster`): the only cluster whose node set is a superset of the given
  61. /// names. Ambiguous (nodes spread across several clusters) → error naming
  62. /// the candidates.
  63. fn cluster_for_node(config: &Config, nodes: &[String]) -> anyhow::Result<Cluster> {
  64. let mut matches: Vec<&Cluster> = config
  65. .clusters
  66. .iter()
  67. .filter(|c| nodes.iter().all(|n| c.nodes.iter().any(|cn| cn.name == *n)))
  68. .collect();
  69. match matches.len() {
  70. 0 => anyhow::bail!("Node(s) {} not found in any cluster", nodes.join(", ")),
  71. 1 => Ok(matches.remove(0).clone()),
  72. n => anyhow::bail!(
  73. "Nodes {} are in {} clusters ({}); add --cluster",
  74. nodes.join(", "),
  75. n,
  76. matches
  77. .iter()
  78. .map(|c| c.name.as_str())
  79. .collect::<Vec<_>>()
  80. .join(", ")
  81. ),
  82. }
  83. }
  84. /// Stop the recipe's containers across `nodes` (all cluster nodes when the
  85. /// list is empty) and verify they are really gone on EVERY node: each node
  86. /// runs the verifying remote command, all nodes are attempted even when a
  87. /// sibling fails, and any failure names the straggler nodes.
  88. pub async fn stop_cluster(
  89. config: &Config,
  90. cluster: &Cluster,
  91. recipe_name: &str,
  92. nodes: &[String],
  93. wait: bool,
  94. dry_run: bool,
  95. ) -> Result<(), anyhow::Error> {
  96. info!(
  97. "{}",
  98. fluent_i18n::t!("cluster-stopping", { "name" => &cluster.name })
  99. );
  100. let nodes: Vec<&Node> = if nodes.is_empty() {
  101. cluster.nodes.iter().collect()
  102. } else {
  103. let mut found: Vec<&Node> = Vec::new();
  104. let mut not_found: Vec<String> = Vec::new();
  105. for nn in nodes {
  106. if let Some(node) = cluster.nodes.iter().find(|n| n.name == *nn) {
  107. found.push(node);
  108. } else {
  109. not_found.push(nn.clone());
  110. }
  111. }
  112. if !not_found.is_empty() {
  113. let names = not_found.join(", ");
  114. anyhow::bail!(
  115. fluent_i18n::t!("exec-node-not-found", { "name" => &names, "cluster" => &cluster.name })
  116. );
  117. }
  118. found
  119. };
  120. let mut handles = Vec::new();
  121. for node in &nodes {
  122. let config = config.clone();
  123. let node = (*node).clone();
  124. let cmd = remote_command(recipe_name, &cluster.name, node.rank, wait);
  125. let dry = dry_run;
  126. let name = node.name.clone();
  127. handles.push(crate::locale::spawn_with_locale(async move {
  128. (name, super::run_ssh(&config, &node, cmd, dry).await)
  129. }));
  130. }
  131. let mut failed = Vec::new();
  132. for r in futures::future::join_all(handles).await {
  133. match r {
  134. Ok((_, Ok(_))) => {}
  135. Ok((name, Err(e))) => failed.push(format!("{name}: {e}")),
  136. // a panicked node task is an unverified node
  137. Err(e) => failed.push(format!("task: {e}")),
  138. }
  139. }
  140. if failed.is_empty() {
  141. info!("{}", fluent_i18n::t!("cluster-all-stopped"));
  142. Ok(())
  143. } else {
  144. let nodes = failed.join(", ");
  145. anyhow::bail!(fluent_i18n::t!("stop-not-verified", { "nodes" => &nodes }))
  146. }
  147. }
  148. /// Handle the `stop` command: stop containers and optionally power off.
  149. ///
  150. /// Waits for the containers to actually stop (see [`remote_command`]), then
  151. /// runs the recipe's `after_stop` hooks and the optional power off.
  152. pub async fn handle(
  153. config: &crate::config::Config,
  154. recipe_name: &str,
  155. cluster_name: &str,
  156. nodes: &[String],
  157. wait: bool,
  158. power_off: bool,
  159. dry_run: bool,
  160. ) -> Result<(), anyhow::Error> {
  161. let (cluster, nodes): (crate::config::Cluster, Vec<String>) =
  162. if !cluster_name.is_empty() && cluster_name != "auto" {
  163. // Explicit cluster selection (exact or prefix): stop that cluster's
  164. // deployment directly — no discovery, no multi-deployment ambiguity.
  165. let cluster = crate::config::select_cluster_by_name(config, cluster_name)?;
  166. (cluster, nodes.to_vec())
  167. } else if !nodes.is_empty() {
  168. // --node without --cluster: find the cluster that contains the node.
  169. let cluster = cluster_for_node(config, nodes)?;
  170. (cluster, nodes.to_vec())
  171. } else {
  172. let variants =
  173. crate::docker::deploy::discover_recipe_deployments(config, recipe_name).await?;
  174. if variants.is_empty() {
  175. anyhow::bail!("No running containers found for recipe '{}'", recipe_name);
  176. }
  177. if variants.len() == 1 {
  178. let v = &variants[0];
  179. let cluster = crate::config::select_cluster_by_name(config, &v.cluster)?;
  180. (cluster, v.nodes.clone())
  181. } else {
  182. anyhow::bail!(crate::docker::deploy::multi_deployment_error(
  183. recipe_name,
  184. &variants
  185. ));
  186. }
  187. };
  188. stop_cluster(config, &cluster, recipe_name, &nodes, wait, dry_run).await?;
  189. // the stop is verified (every node ran the verifying command) — close
  190. // the recipe's open run in the ledger
  191. if !dry_run {
  192. crate::runlock::mark_stop(recipe_name, &config.recipe_sources)?;
  193. }
  194. // Run recipe `after_stop` hooks (e.g. restore_dgx_services) on the stopped nodes; best-effort.
  195. if let Ok(recipe) = crate::config::load_recipe(recipe_name, &config.recipe_sources).await {
  196. crate::after_stop::run(config, &cluster, &recipe, dry_run).await?;
  197. }
  198. if power_off {
  199. crate::power::power_off(config, &cluster, &nodes, dry_run).await?;
  200. }
  201. Ok(())
  202. }
  203. #[cfg(test)]
  204. mod tests {
  205. use super::remote_command;
  206. #[test]
  207. fn default_includes_verification_loop() {
  208. let cmd = remote_command("r", "c", 0, true);
  209. assert!(cmd.contains("xargs -r docker stop"));
  210. assert!(cmd.contains("docker ps -aq"));
  211. assert!(cmd.contains("xargs -r docker rm"));
  212. assert!(cmd.contains("exit 1"));
  213. assert!(cmd.ends_with("true"));
  214. }
  215. #[test]
  216. fn no_wait_omits_verification_loop() {
  217. let cmd = remote_command("r", "c", 1, false);
  218. assert!(!cmd.contains("seq 1 30"));
  219. assert!(!cmd.contains("docker rm"));
  220. assert!(!cmd.contains("exit 1"));
  221. assert!(cmd.contains("/tmp/sm121_r_1"));
  222. }
  223. }