before_serve.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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 config-level hooks with
  6. /// recipe-level overrides (`before_serve = [...]` in recipe.toml). Config
  7. /// hooks come from `[before_serve."*"]` (global — runs for every cluster)
  8. /// followed by `[before_serve."<cluster>"]` (per-cluster), then recipe —
  9. /// each in declared order — so per-cluster can supplement/override the
  10. /// global list and recipe commands run last.
  11. ///
  12. /// Per-command behaviour on a non-zero exit is controlled by
  13. /// [`PreServeCommand::on_failure`]:
  14. /// - `abort` (default) — fail-fast: abort the whole serve
  15. /// - `warn` — log warning, continue with remaining commands
  16. /// - `continue`— stay silent, continue
  17. ///
  18. /// If `skip` is true the function returns without running anything.
  19. pub async fn run(
  20. config: &Config,
  21. cluster: &Cluster,
  22. recipe: &Recipe,
  23. skip: bool,
  24. dry_run: bool,
  25. ) -> Result<()> {
  26. if skip {
  27. info!("{}", fluent_i18n::t!("pre-serve-skip"));
  28. return Ok(());
  29. }
  30. let cmds = combined_commands(config, cluster, recipe);
  31. if cmds.is_empty() {
  32. return Ok(());
  33. }
  34. info!(
  35. "🔧 Pre-serve hooks: {} command(s) on {} node(s)",
  36. cmds.len(),
  37. cluster.nodes.len()
  38. );
  39. // Run all commands for one node sequentially (so order matters per
  40. // node), and the nodes in parallel.
  41. let mut handles = Vec::with_capacity(cluster.nodes.len());
  42. for node in &cluster.nodes {
  43. let config = config.clone();
  44. let node = node.clone();
  45. let cmds = cmds.clone();
  46. let dry = dry_run;
  47. handles.push(crate::locale::spawn_with_locale(async move {
  48. (
  49. node.name.clone(),
  50. run_for_node(&config, &node, &cmds, dry).await,
  51. )
  52. }));
  53. }
  54. let mut any_failed = false;
  55. for h in handles {
  56. let (name, result) = h.await?;
  57. if let Err(e) = result {
  58. any_failed = true;
  59. warn!(
  60. "{}",
  61. fluent_i18n::t!("pre-serve-failed", { "name" => &name, "error" => e.to_string() })
  62. );
  63. }
  64. }
  65. if any_failed {
  66. anyhow::bail!("Pre-serve hooks failed on at least one node (see warnings above)");
  67. }
  68. Ok(())
  69. }
  70. /// Combine cluster-level + recipe-level commands into one ordered list.
  71. fn combined_commands(
  72. config: &Config,
  73. cluster: &Cluster,
  74. recipe: &Recipe,
  75. ) -> Vec<(&'static str, PreServeCommand)> {
  76. let mut out: Vec<(&'static str, PreServeCommand)> = Vec::new();
  77. // Global hooks (literal "*" key — not a glob): run for every cluster.
  78. if let Some(table) = config.before_serve.get("*") {
  79. for c in &table.commands {
  80. out.push(("global", c.clone()));
  81. }
  82. }
  83. if let Some(table) = config.before_serve.get(&cluster.name) {
  84. for c in &table.commands {
  85. out.push(("cluster", c.clone()));
  86. }
  87. }
  88. for c in &recipe.before_serve {
  89. out.push(("recipe", c.clone()));
  90. }
  91. out
  92. }
  93. /// Run a fixed list of commands on a single node, sequentially.
  94. /// Returns the first command that failed under `on_failure = abort`.
  95. async fn run_for_node(
  96. config: &Config,
  97. node: &crate::config::Node,
  98. cmds: &[(&'static str, PreServeCommand)],
  99. dry_run: bool,
  100. ) -> Result<()> {
  101. for (origin, cmd) in cmds {
  102. let on_failure = cmd.on_failure();
  103. // Resolve the entry to the SSH command string to run. A `Script`
  104. // alias is uploaded to the node and invoked via `bash`; a raw
  105. // command (`Bare`/`Configured`) runs as-is.
  106. let (label_cmd, ssh_cmd) = match cmd {
  107. PreServeCommand::Script { script, .. } => {
  108. let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
  109. anyhow::anyhow!(
  110. "Unknown built-in script alias '{}' on node {}. Known: {}",
  111. script,
  112. node.name,
  113. crate::builtin_scripts::known_aliases()
  114. )
  115. })?;
  116. let remote = format!("/tmp/sm121-builtin-{}.sh", script);
  117. crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
  118. (format!("script:{script}"), format!("sudo bash {remote}"))
  119. }
  120. _ => {
  121. let c = cmd.command();
  122. (c.to_string(), c.to_string())
  123. }
  124. };
  125. if dry_run {
  126. info!(
  127. "{}",
  128. fluent_i18n::t!("pre-serve-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
  129. );
  130. continue;
  131. }
  132. let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
  133. let status = match &result {
  134. Ok(_) => "ok",
  135. Err(_) => "fail",
  136. };
  137. let label = format!("[{}|{}|{}]", node.name, origin, status);
  138. info!(" {} $ {}", label, label_cmd);
  139. if let Err(ref e) = result {
  140. match on_failure {
  141. OnFailure::Abort => {
  142. return Err(anyhow::anyhow!(
  143. "pre-serve command failed on {}: {}",
  144. node.name,
  145. e
  146. ));
  147. }
  148. OnFailure::Warn => {
  149. warn!(
  150. "{}",
  151. fluent_i18n::t!("pre-serve-ignored", { "cmd" => &node.name, "error" => e.to_string() })
  152. );
  153. }
  154. OnFailure::Continue => {
  155. // silent
  156. }
  157. }
  158. }
  159. }
  160. Ok(())
  161. }
  162. fn truncate(s: &str, max: usize) -> String {
  163. // Iterate by chars (not bytes) so a multibyte boundary inside `max`
  164. // doesn't panic on `&s[..max]` — pre-serve commands often contain
  165. // non-ASCII (Cyrillic/CJK paths, en-dashes).
  166. let head: String = s.chars().take(max).collect();
  167. if s.chars().count() <= max {
  168. s.to_string()
  169. } else {
  170. format!("{head}…")
  171. }
  172. }
  173. #[cfg(test)]
  174. mod tests {
  175. use super::*;
  176. use crate::config::{Node, PreServeCommand};
  177. use crate::pre_serve::VenvSetup;
  178. use std::collections::HashMap;
  179. fn test_cluster() -> Cluster {
  180. Cluster {
  181. name: "c1".to_string(),
  182. master_ib_ip: Some("1.2.3.4".to_string()),
  183. nodes: vec![
  184. Node {
  185. name: "n0".to_string(),
  186. ssh_ip: "10.0.0.1".to_string(),
  187. ib_ip: Some("1.2.3.4".to_string()),
  188. rank: 0,
  189. host_key: None,
  190. entity: None,
  191. },
  192. Node {
  193. name: "n1".to_string(),
  194. ssh_ip: "10.0.0.2".to_string(),
  195. ib_ip: Some("1.2.3.5".to_string()),
  196. rank: 1,
  197. host_key: None,
  198. entity: None,
  199. },
  200. ],
  201. extra_entities: vec![],
  202. }
  203. }
  204. fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
  205. Config {
  206. ssh_user: "u".to_string(),
  207. ssh_key_path: "/k".into(),
  208. clusters: vec![test_cluster()],
  209. nccl: HashMap::new(),
  210. hf_cache_dir: None,
  211. init_port: 25000,
  212. before_serve,
  213. exec_aliases: HashMap::new(),
  214. home_assistant: None,
  215. global_mods_dir: None,
  216. drop_caches: true,
  217. mount_aliases: HashMap::new(),
  218. recipe_sources: Vec::new(),
  219. docker_image_sources: Vec::new(),
  220. cluster_sources: Vec::new(),
  221. }
  222. }
  223. #[test]
  224. fn truncate_keeps_short_string_unchanged() {
  225. assert_eq!(truncate("short", 120), "short");
  226. assert_eq!(truncate("", 120), "");
  227. }
  228. #[test]
  229. fn truncate_shortens_to_char_boundary() {
  230. // 10 chars, cap at 4 → first 4 chars + ellipsis
  231. let out = truncate("abcdefghij", 4);
  232. assert_eq!(out, "abcd…");
  233. }
  234. #[test]
  235. fn truncate_does_not_panic_on_multibyte() {
  236. // Each 'é' is 2 bytes; a byte slice `&s[..3]` would land mid-char and
  237. // panic. The char-based impl must not. (Regression for C-2.)
  238. let s = "éééééé"; // 6 chars, 12 bytes
  239. assert_eq!(truncate(s, 3), "ééé…");
  240. // cap exactly at a multibyte boundary must also be safe
  241. assert_eq!(truncate(s, 4), "éééé…");
  242. }
  243. #[test]
  244. fn truncate_cjk_chars() {
  245. // 3-byte CJK: byte-slice at any non-multiple-of-3 would panic.
  246. let s = "你好世界你好";
  247. assert_eq!(truncate(s, 2), "你好…");
  248. }
  249. #[test]
  250. fn combined_runs_global_then_cluster_then_recipe() {
  251. let mut bs = HashMap::new();
  252. bs.insert(
  253. "c1".to_string(),
  254. crate::config::BeforeServeTable {
  255. commands: vec![PreServeCommand::Bare("cluster_cmd".into())],
  256. },
  257. );
  258. bs.insert(
  259. "*".to_string(),
  260. crate::config::BeforeServeTable {
  261. commands: vec![PreServeCommand::Bare("global_cmd".into())],
  262. },
  263. );
  264. let cfg = test_config(bs);
  265. let recipe = Recipe {
  266. before_serve: vec![PreServeCommand::Bare("recipe_cmd".into())],
  267. ..test_recipe()
  268. };
  269. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  270. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  271. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  272. assert_eq!(names, vec!["global_cmd", "cluster_cmd", "recipe_cmd"]);
  273. assert_eq!(origins, vec!["global", "cluster", "recipe"]);
  274. }
  275. #[test]
  276. fn combined_global_applies_to_unlisted_cluster() {
  277. let mut bs = HashMap::new();
  278. bs.insert(
  279. "*".to_string(),
  280. crate::config::BeforeServeTable {
  281. commands: vec![PreServeCommand::Bare("global_cmd".into())],
  282. },
  283. );
  284. // Cluster "c1" has no specific entry — only the global hook runs.
  285. let cfg = test_config(bs);
  286. let recipe = Recipe {
  287. before_serve: vec![],
  288. after_stop: vec![],
  289. solo: None,
  290. ..test_recipe()
  291. };
  292. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  293. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  294. assert_eq!(names, vec!["global_cmd"]);
  295. }
  296. #[test]
  297. fn combined_orders_cluster_then_recipe() {
  298. let mut bs = HashMap::new();
  299. bs.insert(
  300. "c1".to_string(),
  301. crate::config::BeforeServeTable {
  302. commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
  303. },
  304. );
  305. let cfg = test_config(bs);
  306. let recipe = Recipe {
  307. before_serve: vec![
  308. PreServeCommand::Bare("recipe_cmd_1".into()),
  309. PreServeCommand::Bare("recipe_cmd_2".into()),
  310. ],
  311. ..test_recipe()
  312. };
  313. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  314. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  315. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  316. assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
  317. assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
  318. }
  319. #[test]
  320. fn combined_empty_when_unconfigured() {
  321. let cfg = test_config(HashMap::new());
  322. let recipe = Recipe {
  323. before_serve: vec![],
  324. after_stop: vec![],
  325. solo: None,
  326. ..test_recipe()
  327. };
  328. assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
  329. }
  330. fn test_recipe() -> Recipe {
  331. Recipe {
  332. build_profile: None,
  333. parent_model: None,
  334. archived: false,
  335. engine: "vllm".to_string(),
  336. name: "Test".to_string(),
  337. docker_image: String::new(),
  338. model: "x".to_string(),
  339. port: 30000,
  340. required_nodes: None,
  341. pipeline_parallel_size: None,
  342. tensor_parallel_size: 1,
  343. args: crate::pre_serve::RecipeArgs::default(),
  344. env: HashMap::new(),
  345. cluster: None,
  346. entrypoint: vec![],
  347. distributed_backend: Some(crate::config::DistributedBackend::Mp),
  348. before_serve: vec![],
  349. after_stop: vec![],
  350. solo: None,
  351. deploy: crate::config::DeployTarget::Docker,
  352. venv: None,
  353. venv_setup: VenvSetup::default(),
  354. venv_setup_script: None,
  355. mods: Default::default(),
  356. sync_image: true,
  357. docker_image_dir: None,
  358. shm_size: String::new(),
  359. extra_mounts: vec![],
  360. build: Default::default(),
  361. composed_model: None,
  362. }
  363. }
  364. // ── Deserialization tests (the ones that live in this module) ──
  365. #[test]
  366. fn parses_cluster_level_before_serve() {
  367. let toml_str = r#"
  368. ssh_user = "u"
  369. ssh_key_path = "/k"
  370. [[clusters]]
  371. name = "c1"
  372. master_ib_ip = "1.1.1.1"
  373. nodes = [
  374. { name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
  375. ]
  376. [before_serve."c1"]
  377. commands = [
  378. "sudo nvidia-smi -lgc 300,2300",
  379. { command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
  380. ]
  381. "#;
  382. let cfg: Config = toml::from_str(toml_str).unwrap();
  383. assert!(cfg.before_serve.contains_key("c1"));
  384. let list = &cfg.before_serve["c1"].commands;
  385. assert_eq!(list.len(), 2);
  386. assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
  387. assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
  388. assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
  389. assert_eq!(list[1].on_failure(), OnFailure::Warn);
  390. }
  391. #[test]
  392. fn parses_recipe_level_before_serve() {
  393. let toml_str = r#"
  394. name = "Demo"
  395. model = "x"
  396. before_serve = [
  397. "echo a",
  398. { command = "echo b", on_failure = "continue" },
  399. ]
  400. "#;
  401. let r: Recipe = toml::from_str(toml_str).unwrap();
  402. assert_eq!(r.before_serve.len(), 2);
  403. assert_eq!(r.before_serve[0].command(), "echo a");
  404. assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
  405. assert_eq!(r.before_serve[1].command(), "echo b");
  406. assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
  407. }
  408. }