before_serve.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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. recipe_name: &str,
  24. skip: bool,
  25. dry_run: bool,
  26. ) -> Result<()> {
  27. if skip {
  28. info!("{}", fluent_i18n::t!("pre-serve-skip"));
  29. return Ok(());
  30. }
  31. let cmds = combined_commands(config, cluster, recipe);
  32. if cmds.is_empty() {
  33. return Ok(());
  34. }
  35. info!(
  36. "🔧 Pre-serve hooks: {} command(s) on {} node(s)",
  37. cmds.len(),
  38. cluster.nodes.len()
  39. );
  40. // A `{ script = "scripts/prepare.sh" }` hook is recipe-relative: resolve
  41. // the recipe's own directory up front (it lives in the shared recipe repo,
  42. // next to recipe.toml — not in this binary).
  43. let recipe_dir = crate::config::recipe_dir(recipe_name, &config.recipe_sources)
  44. .await
  45. .ok();
  46. // Run all commands for one node sequentially (so order matters per
  47. // node), and the nodes in parallel.
  48. let mut handles = Vec::with_capacity(cluster.nodes.len());
  49. for (local_rank, node) in cluster.nodes.iter().enumerate() {
  50. let config = config.clone();
  51. let node = node.clone();
  52. let cmds = cmds.clone();
  53. let dir = recipe_dir.clone();
  54. let dry = dry_run;
  55. handles.push(crate::locale::spawn_with_locale(async move {
  56. (
  57. node.name.clone(),
  58. // Serve-LOCAL rank, not `node.rank`: a subset launch (2 of a
  59. // 4-node cluster) renumbers ranks for the containers, so a
  60. // per-rank staging hook must see the same 0..N-1 index —
  61. // otherwise dgx7/dgx8 in a 4-node cluster stage rank 2/3
  62. // packs for a 2-rank engine (2026-10-21).
  63. run_for_node(
  64. &config,
  65. &node,
  66. local_rank as u32,
  67. &cmds,
  68. dir.as_deref(),
  69. dry,
  70. )
  71. .await,
  72. )
  73. }));
  74. }
  75. let mut any_failed = false;
  76. for h in handles {
  77. let (name, result) = h.await?;
  78. if let Err(e) = result {
  79. any_failed = true;
  80. warn!(
  81. "{}",
  82. fluent_i18n::t!("pre-serve-failed", { "name" => &name, "error" => e.to_string() })
  83. );
  84. }
  85. }
  86. if any_failed {
  87. anyhow::bail!("Pre-serve hooks failed on at least one node (see warnings above)");
  88. }
  89. Ok(())
  90. }
  91. /// Combine cluster-level + recipe-level commands into one ordered list.
  92. fn combined_commands(
  93. config: &Config,
  94. cluster: &Cluster,
  95. recipe: &Recipe,
  96. ) -> Vec<(&'static str, PreServeCommand)> {
  97. let mut out: Vec<(&'static str, PreServeCommand)> = Vec::new();
  98. // Global hooks (literal "*" key — not a glob): run for every cluster.
  99. if let Some(table) = config.before_serve.get("*") {
  100. for c in &table.commands {
  101. out.push(("global", c.clone()));
  102. }
  103. }
  104. if let Some(table) = config.before_serve.get(&cluster.name) {
  105. for c in &table.commands {
  106. out.push(("cluster", c.clone()));
  107. }
  108. }
  109. for c in &recipe.before_serve {
  110. out.push(("recipe", c.clone()));
  111. }
  112. out
  113. }
  114. /// Run a fixed list of commands on a single node, sequentially.
  115. /// Returns the first command that failed under `on_failure = abort`.
  116. async fn run_for_node(
  117. config: &Config,
  118. node: &crate::config::Node,
  119. local_rank: u32,
  120. cmds: &[(&'static str, PreServeCommand)],
  121. recipe_dir: Option<&std::path::Path>,
  122. dry_run: bool,
  123. ) -> Result<()> {
  124. for (origin, cmd) in cmds {
  125. let on_failure = cmd.on_failure();
  126. // Resolve the entry to the SSH command string to run. A `Script`
  127. // alias is uploaded to the node and invoked via `bash`; a raw
  128. // command (`Bare`/`Configured`) runs as-is.
  129. let (label_cmd, ssh_cmd) = match cmd {
  130. PreServeCommand::Script { script, .. } => {
  131. // Built-in alias (generic, cluster-wide helpers baked into the
  132. // binary) or a path relative to the recipe directory (the
  133. // recipe-specific staging script, shipped in the recipe repo so
  134. // it evolves with the recipe instead of with a release).
  135. let (body, tag) = match crate::builtin_scripts::resolve(script) {
  136. Some(body) => (body.to_string(), script.clone()),
  137. None => (
  138. read_recipe_script(script, recipe_dir, &node.name).await?,
  139. script.clone(),
  140. ),
  141. };
  142. let remote = format!(
  143. "/tmp/sm121-hook-{}.sh",
  144. script
  145. .chars()
  146. .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
  147. .collect::<String>()
  148. );
  149. crate::ssh::upload_script(config, node, &body, &remote, dry_run).await?;
  150. // `NODE_RANK` lets a built-in stage per-rank artifacts (e.g. the
  151. // TensorFold Engram packs) without inventing a second mechanism.
  152. (
  153. format!("script:{tag}"),
  154. format!("sudo NODE_RANK={local_rank} bash {remote}"),
  155. )
  156. }
  157. _ => {
  158. let c = cmd.command();
  159. (c.to_string(), c.to_string())
  160. }
  161. };
  162. if dry_run {
  163. info!(
  164. "{}",
  165. fluent_i18n::t!("pre-serve-run", { "node" => &node.name, "tag" => &origin, "cmd" => &truncate(&label_cmd, 120) })
  166. );
  167. continue;
  168. }
  169. let result = crate::ssh::run_ssh(config, node, ssh_cmd, dry_run).await;
  170. let status = match &result {
  171. Ok(_) => "ok",
  172. Err(_) => "fail",
  173. };
  174. let label = format!("[{}|{}|{}]", node.name, origin, status);
  175. info!(" {} $ {}", label, label_cmd);
  176. if let Err(ref e) = result {
  177. match on_failure {
  178. OnFailure::Abort => {
  179. return Err(anyhow::anyhow!(
  180. "pre-serve command failed on {}: {}",
  181. node.name,
  182. e
  183. ));
  184. }
  185. OnFailure::Warn => {
  186. warn!(
  187. "{}",
  188. fluent_i18n::t!("pre-serve-ignored", { "cmd" => &node.name, "error" => e.to_string() })
  189. );
  190. }
  191. OnFailure::Continue => {
  192. // silent
  193. }
  194. }
  195. }
  196. }
  197. Ok(())
  198. }
  199. fn truncate(s: &str, max: usize) -> String {
  200. // Iterate by chars (not bytes) so a multibyte boundary inside `max`
  201. // doesn't panic on `&s[..max]` — pre-serve commands often contain
  202. // non-ASCII (Cyrillic/CJK paths, en-dashes).
  203. let head: String = s.chars().take(max).collect();
  204. if s.chars().count() <= max {
  205. s.to_string()
  206. } else {
  207. format!("{head}…")
  208. }
  209. }
  210. /// Read a recipe-relative hook script (`{ script = "scripts/prepare.sh" }`).
  211. /// The path must stay inside the recipe directory — no absolute paths, no
  212. /// `..` traversal — so a synced recipe repo cannot make the launcher run an
  213. /// arbitrary file.
  214. async fn read_recipe_script(
  215. script: &str,
  216. recipe_dir: Option<&std::path::Path>,
  217. node: &str,
  218. ) -> Result<String> {
  219. let Some(dir) = recipe_dir else {
  220. anyhow::bail!(
  221. "hook script '{script}' is not a built-in alias ({}) and the recipe directory could not be resolved on node {node}",
  222. crate::builtin_scripts::known_aliases()
  223. );
  224. };
  225. if script.starts_with('/') || script.split('/').any(|c| c == "..") {
  226. anyhow::bail!("hook script '{script}' must be a relative path inside the recipe dir");
  227. }
  228. let path = dir.join(script);
  229. tokio::fs::read_to_string(&path).await.map_err(|e| {
  230. anyhow::anyhow!(
  231. "hook script '{script}' not found in {} ({e}); it is not a built-in alias either (known: {})",
  232. dir.display(),
  233. crate::builtin_scripts::known_aliases()
  234. )
  235. })
  236. }
  237. #[cfg(test)]
  238. #[path = "before_serve_tests.rs"]
  239. mod tests;