before_serve.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  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!("{}", fluent_i18n::t!("pre-serve-skip"));
  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!(
  59. "{}",
  60. fluent_i18n::t!("pre-serve-failed", { "name" => &name, "error" => e.to_string() })
  61. );
  62. }
  63. }
  64. if any_failed {
  65. anyhow::bail!("Pre-serve hooks failed on at least one node (see warnings above)");
  66. }
  67. Ok(())
  68. }
  69. /// Combine cluster-level + recipe-level commands into one ordered list.
  70. fn combined_commands(
  71. config: &Config,
  72. cluster: &Cluster,
  73. recipe: &Recipe,
  74. ) -> Vec<(&'static str, PreServeCommand)> {
  75. let mut out: Vec<(&'static str, PreServeCommand)> = Vec::new();
  76. if let Some(table) = config.before_serve.get(&cluster.name) {
  77. for c in &table.commands {
  78. out.push(("cluster", c.clone()));
  79. }
  80. }
  81. for c in &recipe.before_serve {
  82. out.push(("recipe", c.clone()));
  83. }
  84. out
  85. }
  86. /// Run a fixed list of commands on a single node, sequentially.
  87. /// Returns the first command that failed under `on_failure = abort`.
  88. async fn run_for_node(
  89. config: &Config,
  90. node: &crate::config::Node,
  91. cmds: &[(&'static str, PreServeCommand)],
  92. dry_run: bool,
  93. ) -> Result<()> {
  94. for (origin, cmd) in cmds {
  95. let on_failure = cmd.on_failure();
  96. // Resolve the entry to the SSH command string to run. A `Script`
  97. // alias is uploaded to the node and invoked via `bash`; a raw
  98. // command (`Bare`/`Configured`) runs as-is.
  99. let (label_cmd, ssh_cmd) = match cmd {
  100. PreServeCommand::Script { script, .. } => {
  101. let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
  102. anyhow::anyhow!(
  103. "Unknown built-in script alias '{}' on node {}. Known: {}",
  104. script,
  105. node.name,
  106. crate::builtin_scripts::known_aliases()
  107. )
  108. })?;
  109. let remote = format!("/tmp/sm121-builtin-{}.sh", script);
  110. crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
  111. (format!("script:{script}"), format!("sudo bash {remote}"))
  112. }
  113. _ => {
  114. let c = cmd.command();
  115. (c.to_string(), c.to_string())
  116. }
  117. };
  118. if dry_run {
  119. info!(
  120. "{}",
  121. fluent_i18n::t!("pre-serve-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
  122. );
  123. continue;
  124. }
  125. let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
  126. let status = match &result {
  127. Ok(_) => "ok",
  128. Err(_) => "fail",
  129. };
  130. let label = format!("[{}|{}|{}]", node.name, origin, status);
  131. info!(" {} $ {}", label, label_cmd);
  132. if let Err(ref e) = result {
  133. match on_failure {
  134. OnFailure::Abort => {
  135. return Err(anyhow::anyhow!(
  136. "pre-serve command failed on {}: {}",
  137. node.name,
  138. e
  139. ));
  140. }
  141. OnFailure::Warn => {
  142. warn!(
  143. "{}",
  144. fluent_i18n::t!("pre-serve-ignored", { "cmd" => &node.name, "error" => e.to_string() })
  145. );
  146. }
  147. OnFailure::Continue => {
  148. // silent
  149. }
  150. }
  151. }
  152. }
  153. Ok(())
  154. }
  155. fn truncate(s: &str, max: usize) -> String {
  156. // Iterate by chars (not bytes) so a multibyte boundary inside `max`
  157. // doesn't panic on `&s[..max]` — pre-serve commands often contain
  158. // non-ASCII (Cyrillic/CJK paths, en-dashes).
  159. let head: String = s.chars().take(max).collect();
  160. if s.chars().count() <= max {
  161. s.to_string()
  162. } else {
  163. format!("{head}…")
  164. }
  165. }
  166. #[cfg(test)]
  167. mod tests {
  168. use super::*;
  169. use crate::config::{Node, PreServeCommand};
  170. use std::collections::HashMap;
  171. fn test_cluster() -> Cluster {
  172. Cluster {
  173. name: "c1".to_string(),
  174. master_ib_ip: "1.2.3.4".to_string(),
  175. nodes: vec![
  176. Node {
  177. name: "n0".to_string(),
  178. ssh_ip: "10.0.0.1".to_string(),
  179. ib_ip: "1.2.3.4".to_string(),
  180. rank: 0,
  181. host_key: None,
  182. entity: None,
  183. },
  184. Node {
  185. name: "n1".to_string(),
  186. ssh_ip: "10.0.0.2".to_string(),
  187. ib_ip: "1.2.3.5".to_string(),
  188. rank: 1,
  189. host_key: None,
  190. entity: None,
  191. },
  192. ],
  193. extra_entities: vec![],
  194. }
  195. }
  196. fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
  197. Config {
  198. ssh_user: "u".to_string(),
  199. ssh_key_path: "/k".into(),
  200. clusters: vec![test_cluster()],
  201. nccl: HashMap::new(),
  202. hf_cache_dir: None,
  203. init_port: 25000,
  204. before_serve,
  205. exec_aliases: HashMap::new(),
  206. home_assistant: None,
  207. global_mods_dir: None,
  208. drop_caches: true,
  209. mount_aliases: HashMap::new(),
  210. }
  211. }
  212. #[test]
  213. fn truncate_keeps_short_string_unchanged() {
  214. assert_eq!(truncate("short", 120), "short");
  215. assert_eq!(truncate("", 120), "");
  216. }
  217. #[test]
  218. fn truncate_shortens_to_char_boundary() {
  219. // 10 chars, cap at 4 → first 4 chars + ellipsis
  220. let out = truncate("abcdefghij", 4);
  221. assert_eq!(out, "abcd…");
  222. }
  223. #[test]
  224. fn truncate_does_not_panic_on_multibyte() {
  225. // Each 'é' is 2 bytes; a byte slice `&s[..3]` would land mid-char and
  226. // panic. The char-based impl must not. (Regression for C-2.)
  227. let s = "éééééé"; // 6 chars, 12 bytes
  228. assert_eq!(truncate(s, 3), "ééé…");
  229. // cap exactly at a multibyte boundary must also be safe
  230. assert_eq!(truncate(s, 4), "éééé…");
  231. }
  232. #[test]
  233. fn truncate_cjk_chars() {
  234. // 3-byte CJK: byte-slice at any non-multiple-of-3 would panic.
  235. let s = "你好世界你好";
  236. assert_eq!(truncate(s, 2), "你好…");
  237. }
  238. #[test]
  239. fn combined_orders_cluster_then_recipe() {
  240. let mut bs = HashMap::new();
  241. bs.insert(
  242. "c1".to_string(),
  243. crate::config::BeforeServeTable {
  244. commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
  245. },
  246. );
  247. let cfg = test_config(bs);
  248. let recipe = Recipe {
  249. before_serve: vec![
  250. PreServeCommand::Bare("recipe_cmd_1".into()),
  251. PreServeCommand::Bare("recipe_cmd_2".into()),
  252. ],
  253. ..test_recipe()
  254. };
  255. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  256. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  257. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  258. assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
  259. assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
  260. }
  261. #[test]
  262. fn combined_empty_when_unconfigured() {
  263. let cfg = test_config(HashMap::new());
  264. let recipe = Recipe {
  265. before_serve: vec![],
  266. after_stop: vec![],
  267. solo: None,
  268. ..test_recipe()
  269. };
  270. assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
  271. }
  272. fn test_recipe() -> Recipe {
  273. Recipe {
  274. engine: "vllm".to_string(),
  275. name: "Test".to_string(),
  276. docker_image: String::new(),
  277. model: "x".to_string(),
  278. port: 30000,
  279. required_nodes: None,
  280. pipeline_parallel_size: None,
  281. tensor_parallel_size: 1,
  282. args: crate::config::RecipeArgs::default(),
  283. env: HashMap::new(),
  284. cluster: None,
  285. entrypoint: vec![],
  286. distributed_backend: Some(crate::config::DistributedBackend::Mp),
  287. before_serve: vec![],
  288. after_stop: vec![],
  289. solo: None,
  290. mods: Default::default(),
  291. sync_image: true,
  292. docker_image_dir: None,
  293. shm_size: String::new(),
  294. extra_mounts: vec![],
  295. build: Default::default(),
  296. }
  297. }
  298. // ── Deserialization tests (the ones that live in this module) ──
  299. #[test]
  300. fn parses_cluster_level_before_serve() {
  301. let toml_str = r#"
  302. ssh_user = "u"
  303. ssh_key_path = "/k"
  304. [[clusters]]
  305. name = "c1"
  306. master_ib_ip = "1.1.1.1"
  307. nodes = [
  308. { name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
  309. ]
  310. [before_serve."c1"]
  311. commands = [
  312. "sudo nvidia-smi -lgc 300,2300",
  313. { command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
  314. ]
  315. "#;
  316. let cfg: Config = toml::from_str(toml_str).unwrap();
  317. assert!(cfg.before_serve.contains_key("c1"));
  318. let list = &cfg.before_serve["c1"].commands;
  319. assert_eq!(list.len(), 2);
  320. assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
  321. assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
  322. assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
  323. assert_eq!(list[1].on_failure(), OnFailure::Warn);
  324. }
  325. #[test]
  326. fn parses_recipe_level_before_serve() {
  327. let toml_str = r#"
  328. name = "Demo"
  329. model = "x"
  330. before_serve = [
  331. "echo a",
  332. { command = "echo b", on_failure = "continue" },
  333. ]
  334. "#;
  335. let r: Recipe = toml::from_str(toml_str).unwrap();
  336. assert_eq!(r.before_serve.len(), 2);
  337. assert_eq!(r.before_serve[0].command(), "echo a");
  338. assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
  339. assert_eq!(r.before_serve[1].command(), "echo b");
  340. assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
  341. }
  342. }