after_stop.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. //! `after_stop` — recipe commands that run on each node **after** containers
  2. //! are stopped (`sm121 stop`). The mirror of [`crate::before_serve`], for the
  3. //! teardown side of a serve: e.g. `restore_dgx_services` to bring back the
  4. //! services that a `before_serve` `stop_dgx_services` stopped.
  5. //!
  6. //! Same entry shape ([`PreServeCommand`]: `Bare` / `Configured` / `Script`)
  7. //! and the same `on_failure` semantics as `before_serve`. Recipe-only (no
  8. //! cluster-level `[after_stop]` config); runs per-node in parallel, commands
  9. //! sequentially within a node.
  10. use anyhow::Result;
  11. use tracing::{info, warn};
  12. use crate::config::{Cluster, Config, Node, OnFailure, PreServeCommand, Recipe};
  13. use crate::ssh::shq;
  14. /// Run a recipe's `after_stop` commands on every node of the cluster after
  15. /// containers are stopped. See module docs for semantics.
  16. pub async fn run(config: &Config, cluster: &Cluster, recipe: &Recipe, dry_run: bool) -> Result<()> {
  17. if recipe.after_stop.is_empty() {
  18. return Ok(());
  19. }
  20. info!(
  21. "🔧 after_stop hooks: {} command(s) on {} node(s)",
  22. recipe.after_stop.len(),
  23. cluster.nodes.len()
  24. );
  25. let cmds: Vec<(&'static str, PreServeCommand)> = recipe
  26. .after_stop
  27. .iter()
  28. .map(|c| ("recipe", c.clone()))
  29. .collect();
  30. let mut handles = Vec::with_capacity(cluster.nodes.len());
  31. for node in &cluster.nodes {
  32. let config = config.clone();
  33. let node = node.clone();
  34. let cmds = cmds.clone();
  35. let dry = dry_run;
  36. handles.push(crate::locale::spawn_with_locale(async move {
  37. (
  38. node.name.clone(),
  39. run_for_node(&config, &node, &cmds, dry).await,
  40. )
  41. }));
  42. }
  43. let mut any_failed = false;
  44. for h in handles {
  45. let (name, result) = h.await?;
  46. if let Err(e) = result {
  47. any_failed = true;
  48. warn!(
  49. "{}",
  50. fluent_i18n::t!("after-stop-failed", { "name" => &name, "error" => e.to_string() })
  51. );
  52. }
  53. }
  54. if any_failed {
  55. anyhow::bail!("after_stop hooks failed on at least one node (see warnings above)");
  56. }
  57. Ok(())
  58. }
  59. /// Run a fixed list of commands on a single node, sequentially. Mirrors
  60. /// `before_serve::run_for_node`; `Script` entries are uploaded + invoked via
  61. /// `bash`, raw entries run as-is.
  62. async fn run_for_node(
  63. config: &Config,
  64. node: &Node,
  65. cmds: &[(&'static str, PreServeCommand)],
  66. dry_run: bool,
  67. ) -> Result<()> {
  68. for (origin, cmd) in cmds {
  69. let on_failure = cmd.on_failure();
  70. let (label_cmd, ssh_cmd) = match cmd {
  71. PreServeCommand::Script { script, .. } => {
  72. let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
  73. anyhow::anyhow!(
  74. "Unknown built-in script alias '{}' on node {}. Known: {}",
  75. script,
  76. node.name,
  77. crate::builtin_scripts::known_aliases()
  78. )
  79. })?;
  80. let remote = format!("/tmp/sm121-builtin-{script}.sh");
  81. crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
  82. (
  83. format!("script:{script}"),
  84. format!("sudo bash {}", shq(&remote)),
  85. )
  86. }
  87. _ => {
  88. let c = cmd.command();
  89. (c.to_string(), c.to_string())
  90. }
  91. };
  92. if dry_run {
  93. info!(
  94. "{}",
  95. fluent_i18n::t!("after-stop-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
  96. );
  97. continue;
  98. }
  99. let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
  100. let status = match &result {
  101. Ok(_) => "ok",
  102. Err(_) => "fail",
  103. };
  104. let label = format!("[{}|{}|{}]", node.name, origin, status);
  105. info!(" {} $ {}", label, label_cmd);
  106. if let Err(ref e) = result {
  107. match on_failure {
  108. OnFailure::Abort => {
  109. return Err(anyhow::anyhow!(
  110. "after_stop command failed on {}: {}",
  111. node.name,
  112. e
  113. ));
  114. }
  115. OnFailure::Warn => warn!(
  116. "{}",
  117. fluent_i18n::t!("after-stop-ignored", { "cmd" => &node.name, "error" => e.to_string() })
  118. ),
  119. OnFailure::Continue => {}
  120. }
  121. }
  122. }
  123. Ok(())
  124. }
  125. fn truncate(s: &str, max: usize) -> String {
  126. // Same char-safe truncate as before_serve (multibyte-safe).
  127. let head: String = s.chars().take(max).collect();
  128. if s.chars().count() <= max {
  129. s.to_string()
  130. } else {
  131. format!("{head}…")
  132. }
  133. }
  134. #[cfg(test)]
  135. mod tests {
  136. use super::*;
  137. use crate::config::{Node, PreServeCommand};
  138. use crate::pre_serve::VenvSetup;
  139. fn test_cluster() -> Cluster {
  140. Cluster {
  141. name: "c1".to_string(),
  142. master_ib_ip: Some("1.2.3.4".to_string()),
  143. nodes: vec![Node {
  144. name: "n0".to_string(),
  145. ssh_ip: "10.0.0.1".to_string(),
  146. ib_ip: Some("1.2.3.4".to_string()),
  147. rank: 0,
  148. host_key: None,
  149. entity: None,
  150. }],
  151. extra_entities: vec![],
  152. }
  153. }
  154. fn test_config() -> Config {
  155. Config {
  156. ssh_user: "u".to_string(),
  157. ssh_key_path: "/k".into(),
  158. clusters: vec![test_cluster()],
  159. nccl: std::collections::HashMap::new(),
  160. hf_cache_dir: None,
  161. init_port: 25000,
  162. before_serve: std::collections::HashMap::new(),
  163. exec_aliases: std::collections::HashMap::new(),
  164. home_assistant: None,
  165. mikrotik: None,
  166. drop_caches: true,
  167. mount_aliases: std::collections::HashMap::new(),
  168. recipe_sources: Vec::new(),
  169. docker_image_sources: Vec::new(),
  170. cluster_sources: Vec::new(),
  171. registry: None,
  172. global_mods_dir: None,
  173. }
  174. }
  175. fn test_recipe(after_stop: Vec<PreServeCommand>) -> Recipe {
  176. Recipe {
  177. build_profile: None,
  178. parent_model: None,
  179. archived: false,
  180. engine: "vllm".to_string(),
  181. name: "T".to_string(),
  182. docker_image: String::new(),
  183. model: "x".to_string(),
  184. port: 30000,
  185. required_nodes: None,
  186. pipeline_parallel_size: None,
  187. tensor_parallel_size: 1,
  188. args: crate::pre_serve::RecipeArgs::default(),
  189. env: std::collections::HashMap::new(),
  190. cluster: None,
  191. entrypoint: vec![],
  192. distributed_backend: Some(crate::config::DistributedBackend::Mp),
  193. before_serve: vec![],
  194. after_stop,
  195. solo: None,
  196. deploy: crate::config::DeployTarget::Docker,
  197. venv: None,
  198. venv_setup: VenvSetup::default(),
  199. venv_setup_script: None,
  200. mods: Default::default(),
  201. sync_image: true,
  202. docker_image_dir: None,
  203. shm_size: String::new(),
  204. extra_mounts: vec![],
  205. build: Default::default(),
  206. composed_model: None,
  207. }
  208. }
  209. #[tokio::test]
  210. async fn empty_after_stop_does_nothing() {
  211. let cfg = test_config();
  212. let c = cfg.clusters[0].clone();
  213. let r = test_recipe(vec![]);
  214. // No hooks → immediate Ok, no SSH.
  215. super::run(&cfg, &c, &r, true).await.unwrap();
  216. }
  217. #[tokio::test]
  218. async fn dry_run_emits_for_each_command() {
  219. let cfg = test_config();
  220. let c = cfg.clusters[0].clone();
  221. let r = test_recipe(vec![
  222. PreServeCommand::Bare("echo done".into()),
  223. PreServeCommand::Script {
  224. script: "restore_dgx_services".into(),
  225. on_failure: OnFailure::Warn,
  226. },
  227. ]);
  228. // Dry-run must not error on the known alias.
  229. super::run(&cfg, &c, &r, true).await.unwrap();
  230. }
  231. #[test]
  232. fn deserialize_after_stop_script_entry() {
  233. let toml_str = r#"
  234. name = "Demo"
  235. model = "x"
  236. after_stop = [
  237. "echo cleaned up",
  238. { script = "restore_dgx_services" },
  239. { script = "restore_dgx_services", on_failure = "warn" },
  240. ]
  241. "#;
  242. let r: Recipe = toml::from_str(toml_str).unwrap();
  243. assert_eq!(r.after_stop.len(), 3);
  244. assert!(matches!(r.after_stop[0], PreServeCommand::Bare(_)));
  245. assert!(matches!(r.after_stop[1], PreServeCommand::Script { .. }));
  246. // Known alias resolves.
  247. assert!(crate::builtin_scripts::resolve("restore_dgx_services").is_some());
  248. }
  249. #[test]
  250. fn deserialize_before_and_after_stop_coexist() {
  251. let toml_str = r#"
  252. name = "Demo"
  253. model = "x"
  254. before_serve = [{ script = "stop_dgx_services" }]
  255. after_stop = [{ script = "restore_dgx_services" }]
  256. "#;
  257. let r: Recipe = toml::from_str(toml_str).unwrap();
  258. assert_eq!(r.before_serve.len(), 1);
  259. assert_eq!(r.after_stop.len(), 1);
  260. }
  261. }