before_serve.rs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. use anyhow::Result;
  2. use tracing::{info, warn};
  3. use crate::config::{Cluster, Config, OnFailure, PreServeCommand, Recipe};
  4. /// Run pre-serve commands on every node of the cluster before launching
  5. /// containers or tmux sessions. Combines cluster-level config
  6. /// (`[before_serve."<cluster>"]`) with recipe-level overrides
  7. /// (`before_serve = [...]` in recipe.toml). Cluster-level commands run
  8. /// first, then recipe-level — each in declared order — so the resulting
  9. /// state is predictable.
  10. ///
  11. /// Per-command behaviour on a non-zero exit is controlled by
  12. /// [`PreServeCommand::on_failure`]:
  13. /// - `abort` (default) — fail-fast: abort the whole serve
  14. /// - `warn` — log warning, continue with remaining commands
  15. /// - `continue`— stay silent, continue
  16. ///
  17. /// If `skip` is true the function returns without running anything.
  18. pub async fn run(
  19. config: &Config,
  20. cluster: &Cluster,
  21. recipe: &Recipe,
  22. skip: bool,
  23. dry_run: bool,
  24. ) -> Result<()> {
  25. if skip {
  26. info!("⏭ Skipping pre-serve hooks (--skip-before-serve)");
  27. return Ok(());
  28. }
  29. let cmds = combined_commands(config, cluster, recipe);
  30. if cmds.is_empty() {
  31. return Ok(());
  32. }
  33. info!(
  34. "🔧 Pre-serve hooks: {} command(s) on {} node(s)",
  35. cmds.len(),
  36. cluster.nodes.len()
  37. );
  38. // Run all commands for one node sequentially (so order matters per
  39. // node), and the nodes in parallel.
  40. let mut handles = Vec::with_capacity(cluster.nodes.len());
  41. for node in &cluster.nodes {
  42. let config = config.clone();
  43. let node = node.clone();
  44. let cmds = cmds.clone();
  45. let dry = dry_run;
  46. handles.push(tokio::spawn(async move {
  47. (
  48. node.name.clone(),
  49. run_for_node(&config, &node, &cmds, dry).await,
  50. )
  51. }));
  52. }
  53. let mut any_failed = false;
  54. for h in handles {
  55. let (name, result) = h.await?;
  56. if let Err(e) = result {
  57. any_failed = true;
  58. warn!("pre-serve: node {} failed: {}", name, e);
  59. }
  60. }
  61. if any_failed {
  62. anyhow::bail!("Pre-serve hooks failed on at least one node (see warnings above)");
  63. }
  64. Ok(())
  65. }
  66. /// Combine cluster-level + recipe-level commands into one ordered list.
  67. fn combined_commands(
  68. config: &Config,
  69. cluster: &Cluster,
  70. recipe: &Recipe,
  71. ) -> Vec<(&'static str, PreServeCommand)> {
  72. let mut out: Vec<(&'static str, PreServeCommand)> = Vec::new();
  73. if let Some(table) = config.before_serve.get(&cluster.name) {
  74. for c in &table.commands {
  75. out.push(("cluster", c.clone()));
  76. }
  77. }
  78. for c in &recipe.before_serve {
  79. out.push(("recipe", c.clone()));
  80. }
  81. out
  82. }
  83. /// Run a fixed list of commands on a single node, sequentially.
  84. /// Returns the first command that failed under `on_failure = abort`.
  85. async fn run_for_node(
  86. config: &Config,
  87. node: &crate::config::Node,
  88. cmds: &[(&'static str, PreServeCommand)],
  89. dry_run: bool,
  90. ) -> Result<()> {
  91. for (origin, cmd) in cmds {
  92. let command = cmd.command();
  93. let on_failure = cmd.on_failure();
  94. if dry_run {
  95. info!(" {} ({}) :: {}", node.name, origin, truncate(command, 120));
  96. continue;
  97. }
  98. let result = crate::ssh::run_ssh(config, node, command.to_string(), dry_run).await;
  99. let status = match &result {
  100. Ok(_) => "ok",
  101. Err(_) => "fail",
  102. };
  103. let label = format!("[{}|{}|{}]", node.name, origin, status);
  104. info!(" {} $ {}", label, command);
  105. if let Err(ref e) = result {
  106. match on_failure {
  107. OnFailure::Abort => {
  108. return Err(anyhow::anyhow!(
  109. "pre-serve command failed on {}: {}",
  110. node.name,
  111. e
  112. ));
  113. }
  114. OnFailure::Warn => {
  115. warn!(" ⚠ {} ignored: {}", node.name, e);
  116. }
  117. OnFailure::Continue => {
  118. // silent
  119. }
  120. }
  121. }
  122. }
  123. Ok(())
  124. }
  125. fn truncate(s: &str, max: usize) -> String {
  126. if s.len() <= max {
  127. s.to_string()
  128. } else {
  129. format!("{}…", &s[..max])
  130. }
  131. }
  132. #[cfg(test)]
  133. mod tests {
  134. use super::*;
  135. use crate::config::{Node, PreServeCommand};
  136. use std::collections::HashMap;
  137. fn test_cluster() -> Cluster {
  138. Cluster {
  139. name: "c1".to_string(),
  140. master_ib_ip: "1.2.3.4".to_string(),
  141. nodes: vec![
  142. Node {
  143. name: "n0".to_string(),
  144. ssh_ip: "10.0.0.1".to_string(),
  145. ib_ip: "1.2.3.4".to_string(),
  146. rank: 0,
  147. host_key: None,
  148. },
  149. Node {
  150. name: "n1".to_string(),
  151. ssh_ip: "10.0.0.2".to_string(),
  152. ib_ip: "1.2.3.5".to_string(),
  153. rank: 1,
  154. host_key: None,
  155. },
  156. ],
  157. }
  158. }
  159. fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
  160. Config {
  161. ssh_user: "u".to_string(),
  162. ssh_key_path: "/k".to_string(),
  163. clusters: vec![test_cluster()],
  164. nccl: HashMap::new(),
  165. hf_cache_dir: None,
  166. init_port: 25000,
  167. before_serve,
  168. exec_aliases: HashMap::new(),
  169. }
  170. }
  171. #[test]
  172. fn combined_orders_cluster_then_recipe() {
  173. let mut bs = HashMap::new();
  174. bs.insert(
  175. "c1".to_string(),
  176. crate::config::BeforeServeTable {
  177. commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
  178. },
  179. );
  180. let cfg = test_config(bs);
  181. let recipe = Recipe {
  182. before_serve: vec![
  183. PreServeCommand::Bare("recipe_cmd_1".into()),
  184. PreServeCommand::Bare("recipe_cmd_2".into()),
  185. ],
  186. ..test_recipe()
  187. };
  188. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  189. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  190. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  191. assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
  192. assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
  193. }
  194. #[test]
  195. fn combined_empty_when_unconfigured() {
  196. let cfg = test_config(HashMap::new());
  197. let recipe = Recipe {
  198. before_serve: vec![],
  199. ..test_recipe()
  200. };
  201. assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
  202. }
  203. fn test_recipe() -> Recipe {
  204. Recipe {
  205. engine: "vllm".to_string(),
  206. name: "Test".to_string(),
  207. docker_image: String::new(),
  208. model: "x".to_string(),
  209. port: 0,
  210. required_nodes: None,
  211. pipeline_parallel_size: None,
  212. tensor_parallel_size: 1,
  213. args: vec![],
  214. env: HashMap::new(),
  215. cluster: None,
  216. entrypoint: vec![],
  217. distributed_backend: "mp".to_string(),
  218. before_serve: vec![],
  219. }
  220. }
  221. // ── Deserialization tests (the ones that live in this module) ──
  222. #[test]
  223. fn parses_cluster_level_before_serve() {
  224. let toml_str = r#"
  225. ssh_user = "u"
  226. ssh_key_path = "/k"
  227. [[clusters]]
  228. name = "c1"
  229. master_ib_ip = "1.1.1.1"
  230. nodes = [
  231. { name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
  232. ]
  233. [before_serve."c1"]
  234. commands = [
  235. "sudo nvidia-smi -lgc 300,2300",
  236. { command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
  237. ]
  238. "#;
  239. let cfg: Config = toml::from_str(toml_str).unwrap();
  240. assert!(cfg.before_serve.contains_key("c1"));
  241. let list = &cfg.before_serve["c1"].commands;
  242. assert_eq!(list.len(), 2);
  243. assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
  244. assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
  245. assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
  246. assert_eq!(list[1].on_failure(), OnFailure::Warn);
  247. }
  248. #[test]
  249. fn parses_recipe_level_before_serve() {
  250. let toml_str = r#"
  251. name = "Demo"
  252. model = "x"
  253. before_serve = [
  254. "echo a",
  255. { command = "echo b", on_failure = "continue" },
  256. ]
  257. "#;
  258. let r: Recipe = toml::from_str(toml_str).unwrap();
  259. assert_eq!(r.before_serve.len(), 2);
  260. assert_eq!(r.before_serve[0].command(), "echo a");
  261. assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
  262. assert_eq!(r.before_serve[1].command(), "echo b");
  263. assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
  264. }
  265. }