mods.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. //! Mods: commands that run *inside* the container via entrypoint wrapping,
  2. //! before the engine process starts. Each command runs inside `sh -c` on
  3. //! container start; the original entrypoint is `exec`'d at the end so the
  4. //! engine becomes PID 1 and signals pass through correctly.
  5. use anyhow::{Context, Result};
  6. use tracing::info;
  7. use crate::config::{Cluster, Config, Node, OnFailure, PreServeCommand, Recipe, resolve_mods_dir};
  8. use crate::ssh::upload_dir;
  9. use super::shq;
  10. /// Build a shell script that runs `mods` then `exec`s the original tail
  11. /// (entrypoint + model + args + auto-generated flags).
  12. ///
  13. /// Mod commands are shell code (piped directly into the script) — only
  14. /// embedded single quotes are escaped so the outer [`shq`] wrapper doesn't
  15. /// break. Each tail element is individually [`shq`]'d so multi-word or
  16. /// special-containing parts survive word-splitting inside `sh -c`.
  17. ///
  18. /// The returned string is already wrapped in single quotes (via `shq`),
  19. /// ready to use as the `-c` argument to `sh -c`.
  20. pub fn build_mod_script(mods: &[PreServeCommand], tail: &[String]) -> String {
  21. let mut script = String::new();
  22. for m in mods {
  23. // Mod commands are shell code — do NOT wrap in shq (which would
  24. // prevent word splitting). Embed directly; the outer shq wrapper
  25. // handles any embedded single quotes.
  26. let cmd = m.command();
  27. match m.on_failure() {
  28. OnFailure::Abort => {
  29. script.push_str(&format!("{cmd} || exit 1; "));
  30. }
  31. OnFailure::Warn | OnFailure::Continue => {
  32. // NOTE: `|| true` swallows the exit code and stderr is
  33. // lost to the container log. If mod debugging is needed,
  34. // consider redirecting stderr to a temp file, e.g.:
  35. // cmd 2>/tmp/sm121-mod-err.log; true
  36. script.push_str(&format!("{cmd} || true; "));
  37. }
  38. }
  39. }
  40. script.push_str("exec ");
  41. for part in tail {
  42. script.push_str(&shq(part));
  43. script.push(' ');
  44. }
  45. let trimmed = script.trim_end().to_string();
  46. shq(&trimmed)
  47. }
  48. /// Upload the mods directory to every node and return the remote path, or
  49. /// empty string when no mods directory exists. Also validates that
  50. /// `entrypoint` is set when mods are present.
  51. pub async fn upload_mods_to_cluster(
  52. config: &Config,
  53. cluster: &Cluster,
  54. recipe: &Recipe,
  55. recipe_name: &str,
  56. node_name: Option<&str>,
  57. dry_run: bool,
  58. ) -> Result<String> {
  59. if recipe.mods.commands.is_empty() {
  60. return Ok(String::new());
  61. }
  62. if recipe.entrypoint.is_empty() {
  63. anyhow::bail!(
  64. "{}",
  65. fluent_i18n::t!("mods-entrypoint-required", { "name" => &recipe.name })
  66. );
  67. }
  68. let nodes: Vec<&Node> = if let Some(ref nn) = node_name {
  69. vec![
  70. cluster
  71. .nodes
  72. .iter()
  73. .find(|n| n.name == *nn)
  74. .context(format!(
  75. "Node '{}' not found in cluster '{}'",
  76. nn, cluster.name
  77. ))?,
  78. ]
  79. } else {
  80. cluster.nodes.iter().collect()
  81. };
  82. let remote = format!("/tmp/sm121-mods-{}", recipe_name);
  83. // Upload global mods first (shared scripts), then recipe mods on top
  84. // (recipe-specific overrides). Both are merged into the same /mods/ dir.
  85. if let Some(ref global_dir) = config.global_mods_dir
  86. && global_dir.is_dir()
  87. {
  88. for node in &nodes {
  89. upload_dir(config, node, global_dir, &remote, dry_run).await?;
  90. }
  91. }
  92. if let Some(local_mods_dir) = resolve_mods_dir(recipe_name).await {
  93. for node in &nodes {
  94. info!(
  95. "{}",
  96. fluent_i18n::t!("mods-uploading", { "node" => &node.name })
  97. );
  98. upload_dir(config, node, &local_mods_dir, &remote, dry_run).await?;
  99. info!(
  100. "{}",
  101. fluent_i18n::t!("mods-uploaded", { "node" => &node.name })
  102. );
  103. }
  104. }
  105. Ok(remote)
  106. }
  107. #[cfg(test)]
  108. fn unshq(s: &str) -> String {
  109. let inner = s
  110. .strip_prefix('\'')
  111. .and_then(|s| s.strip_suffix('\''))
  112. .unwrap_or(s);
  113. inner.replace(r"'\''", "'")
  114. }
  115. #[cfg(test)]
  116. mod tests {
  117. use super::*;
  118. use crate::config::PreServeCommand;
  119. #[test]
  120. fn simple_mods_wraps_tail() {
  121. let mods = vec![
  122. PreServeCommand::Bare("pip install flash-attn".into()),
  123. PreServeCommand::Bare("python patch.py".into()),
  124. ];
  125. let tail = vec![
  126. "vllm".to_string(),
  127. "serve".to_string(),
  128. "org/model".to_string(),
  129. "--host".to_string(),
  130. "0.0.0.0".to_string(),
  131. ];
  132. let script = build_mod_script(&mods, &tail);
  133. assert!(script.starts_with('\''), "shq-wrapped: {script}");
  134. assert!(script.ends_with('\''), "shq-wrapped: {script}");
  135. let inner = unshq(&script);
  136. // Mod commands are NOT shq-wrapped — they're raw shell code
  137. assert!(inner.starts_with("pip install flash-attn || exit 1; "));
  138. assert!(inner.contains("python patch.py || exit 1; "));
  139. assert!(
  140. inner.contains("exec 'vllm' 'serve' 'org/model' '--host' '0.0.0.0'"),
  141. "inner: {inner}"
  142. );
  143. }
  144. #[test]
  145. fn on_failure_abort_exits_on_error() {
  146. let mods = vec![PreServeCommand::Bare("false".into())];
  147. let tail = vec!["echo".to_string(), "ok".to_string()];
  148. let script = build_mod_script(&mods, &tail);
  149. let inner = unshq(&script);
  150. assert!(inner.contains("|| exit 1"));
  151. }
  152. #[test]
  153. fn on_failure_warn_continues() {
  154. let mods = vec![PreServeCommand::Configured {
  155. command: "false".into(),
  156. on_failure: OnFailure::Warn,
  157. }];
  158. let tail = vec!["echo".to_string(), "ok".to_string()];
  159. let script = build_mod_script(&mods, &tail);
  160. let inner = unshq(&script);
  161. assert!(inner.contains("|| true"), "warn continues: {inner}");
  162. assert!(inner.contains("exec"));
  163. }
  164. #[test]
  165. fn on_failure_continue_is_silent() {
  166. let mods = vec![PreServeCommand::Configured {
  167. command: "false".into(),
  168. on_failure: OnFailure::Continue,
  169. }];
  170. let tail = vec!["true".to_string()];
  171. let script = build_mod_script(&mods, &tail);
  172. let inner = unshq(&script);
  173. assert!(inner.contains("|| true"));
  174. assert!(inner.contains("exec 'true'"));
  175. }
  176. #[test]
  177. fn empty_mods_preserves_tail() {
  178. let mods = vec![];
  179. let tail = vec!["vllm".to_string(), "serve".to_string(), "noop".to_string()];
  180. let script = build_mod_script(&mods, &tail);
  181. let inner = unshq(&script);
  182. assert_eq!(inner, "exec 'vllm' 'serve' 'noop'");
  183. }
  184. #[test]
  185. fn escaping_special_chars_in_commands() {
  186. let mods = vec![PreServeCommand::Bare("echo 'hello world'".into())];
  187. let tail = vec!["true".to_string()];
  188. let script = build_mod_script(&mods, &tail);
  189. // The inner single quote in `echo 'hello world'` gets escaped as
  190. // '\'' so the outer shq doesn't break. After unshq, the inner
  191. // script should have `echo '\''hello world'\''` (the outer shq
  192. // wrapper's quotes are stripped, but the escaped inner quotes
  193. // are restored to literal single quotes).
  194. let inner = unshq(&script);
  195. assert!(
  196. inner.contains("echo 'hello world'"),
  197. "inner single quotes should survive: {inner}"
  198. );
  199. }
  200. #[test]
  201. fn mods_with_entrypoint_sh_c() {
  202. let mods = vec![PreServeCommand::Bare("echo mod".into())];
  203. let tail = vec![
  204. "sh".to_string(),
  205. "-c".to_string(),
  206. "i=0; while true; do echo $i; i=$((i+1)); done".to_string(),
  207. "noop".to_string(),
  208. ];
  209. let script = build_mod_script(&mods, &tail);
  210. let inner = unshq(&script);
  211. assert!(inner.starts_with("echo mod || exit 1;"));
  212. assert!(inner.contains("exec 'sh' '-c' '"));
  213. assert!(inner.contains("'noop'"));
  214. }
  215. #[test]
  216. fn mods_wrapping_uses_entrypoint_sh() {
  217. let mods = vec![PreServeCommand::Bare("pip list".into())];
  218. let tail = vec!["vllm".to_string(), "serve".to_string()];
  219. let script = build_mod_script(&mods, &tail);
  220. let inner = unshq(&script);
  221. assert!(
  222. inner.contains("exec 'vllm' 'serve'"),
  223. "tail should be exec'd: {inner}"
  224. );
  225. }
  226. #[test]
  227. fn deserialize_mods_toml() {
  228. let toml_str = r#"
  229. name = "Modded"
  230. model = "test/model"
  231. entrypoint = ["vllm", "serve"]
  232. [mods]
  233. commands = [
  234. "pip install flash-attn",
  235. { command = "./patch.sh", on_failure = "warn" },
  236. ]
  237. "#;
  238. let recipe: crate::config::Recipe = toml::from_str(toml_str).unwrap();
  239. assert_eq!(recipe.mods.commands.len(), 2);
  240. assert_eq!(recipe.mods.commands[0].command(), "pip install flash-attn");
  241. assert_eq!(recipe.mods.commands[0].on_failure(), OnFailure::Abort);
  242. assert_eq!(recipe.mods.commands[1].command(), "./patch.sh");
  243. assert_eq!(recipe.mods.commands[1].on_failure(), OnFailure::Warn);
  244. }
  245. }