before_serve_tests.rs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. //! Tests for `before_serve` (split at the 500-line cap).
  2. use super::*;
  3. use crate::config::{Node, PreServeCommand};
  4. use crate::pre_serve::VenvSetup;
  5. use std::collections::HashMap;
  6. fn test_cluster() -> Cluster {
  7. Cluster {
  8. name: "c1".to_string(),
  9. master_ib_ip: Some("1.2.3.4".to_string()),
  10. nodes: vec![
  11. Node {
  12. name: "n0".to_string(),
  13. ssh_ip: "10.0.0.1".to_string(),
  14. ib_ip: Some("1.2.3.4".to_string()),
  15. rank: 0,
  16. host_key: None,
  17. entity: None,
  18. },
  19. Node {
  20. name: "n1".to_string(),
  21. ssh_ip: "10.0.0.2".to_string(),
  22. ib_ip: Some("1.2.3.5".to_string()),
  23. rank: 1,
  24. host_key: None,
  25. entity: None,
  26. },
  27. ],
  28. extra_entities: vec![],
  29. }
  30. }
  31. fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
  32. Config {
  33. cache: None,
  34. flare: None,
  35. ssh_user: "u".to_string(),
  36. ssh_key_path: "/k".into(),
  37. clusters: vec![test_cluster()],
  38. nccl: HashMap::new(),
  39. hf_cache_dir: None,
  40. init_port: 25000,
  41. before_serve,
  42. exec_aliases: HashMap::new(),
  43. home_assistant: None,
  44. mikrotik: None,
  45. global_mods_dir: None,
  46. drop_caches: true,
  47. mount_aliases: HashMap::new(),
  48. recipe_sources: Vec::new(),
  49. recipe_repos: Vec::new(),
  50. docker_image_sources: Vec::new(),
  51. cluster_sources: Vec::new(),
  52. registry: None,
  53. }
  54. }
  55. #[test]
  56. fn truncate_keeps_short_string_unchanged() {
  57. assert_eq!(truncate("short", 120), "short");
  58. assert_eq!(truncate("", 120), "");
  59. }
  60. #[test]
  61. fn truncate_shortens_to_char_boundary() {
  62. // 10 chars, cap at 4 → first 4 chars + ellipsis
  63. let out = truncate("abcdefghij", 4);
  64. assert_eq!(out, "abcd…");
  65. }
  66. #[test]
  67. fn truncate_does_not_panic_on_multibyte() {
  68. // Each 'é' is 2 bytes; a byte slice `&s[..3]` would land mid-char and
  69. // panic. The char-based impl must not. (Regression for C-2.)
  70. let s = "éééééé"; // 6 chars, 12 bytes
  71. assert_eq!(truncate(s, 3), "ééé…");
  72. // cap exactly at a multibyte boundary must also be safe
  73. assert_eq!(truncate(s, 4), "éééé…");
  74. }
  75. #[test]
  76. fn truncate_cjk_chars() {
  77. // 3-byte CJK: byte-slice at any non-multiple-of-3 would panic.
  78. let s = "你好世界你好";
  79. assert_eq!(truncate(s, 2), "你好…");
  80. }
  81. #[test]
  82. fn combined_runs_global_then_cluster_then_recipe() {
  83. let mut bs = HashMap::new();
  84. bs.insert(
  85. "c1".to_string(),
  86. crate::config::BeforeServeTable {
  87. commands: vec![PreServeCommand::Bare("cluster_cmd".into())],
  88. },
  89. );
  90. bs.insert(
  91. "*".to_string(),
  92. crate::config::BeforeServeTable {
  93. commands: vec![PreServeCommand::Bare("global_cmd".into())],
  94. },
  95. );
  96. let cfg = test_config(bs);
  97. let recipe = Recipe {
  98. before_serve: vec![PreServeCommand::Bare("recipe_cmd".into())],
  99. ..test_recipe()
  100. };
  101. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  102. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  103. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  104. assert_eq!(names, vec!["global_cmd", "cluster_cmd", "recipe_cmd"]);
  105. assert_eq!(origins, vec!["global", "cluster", "recipe"]);
  106. }
  107. #[test]
  108. fn combined_global_applies_to_unlisted_cluster() {
  109. let mut bs = HashMap::new();
  110. bs.insert(
  111. "*".to_string(),
  112. crate::config::BeforeServeTable {
  113. commands: vec![PreServeCommand::Bare("global_cmd".into())],
  114. },
  115. );
  116. // Cluster "c1" has no specific entry — only the global hook runs.
  117. let cfg = test_config(bs);
  118. let recipe = Recipe {
  119. before_serve: vec![],
  120. after_stop: vec![],
  121. solo: None,
  122. ..test_recipe()
  123. };
  124. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  125. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  126. assert_eq!(names, vec!["global_cmd"]);
  127. }
  128. #[test]
  129. fn combined_orders_cluster_then_recipe() {
  130. let mut bs = HashMap::new();
  131. bs.insert(
  132. "c1".to_string(),
  133. crate::config::BeforeServeTable {
  134. commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
  135. },
  136. );
  137. let cfg = test_config(bs);
  138. let recipe = Recipe {
  139. before_serve: vec![
  140. PreServeCommand::Bare("recipe_cmd_1".into()),
  141. PreServeCommand::Bare("recipe_cmd_2".into()),
  142. ],
  143. ..test_recipe()
  144. };
  145. let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
  146. let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
  147. let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
  148. assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
  149. assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
  150. }
  151. #[test]
  152. fn combined_empty_when_unconfigured() {
  153. let cfg = test_config(HashMap::new());
  154. let recipe = Recipe {
  155. before_serve: vec![],
  156. after_stop: vec![],
  157. solo: None,
  158. ..test_recipe()
  159. };
  160. assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
  161. }
  162. fn test_recipe() -> Recipe {
  163. Recipe {
  164. flare: None,
  165. build_profile: None,
  166. parent_model: None,
  167. archived: false,
  168. engine: "vllm".to_string(),
  169. name: "Test".to_string(),
  170. docker_image: String::new(),
  171. model: "x".to_string(),
  172. port: 30000,
  173. required_nodes: None,
  174. pipeline_parallel_size: None,
  175. tensor_parallel_size: 1,
  176. args: crate::pre_serve::RecipeArgs::default(),
  177. cold_args: crate::pre_serve::RecipeArgs::default(),
  178. env: HashMap::new(),
  179. cluster: None,
  180. entrypoint: vec![],
  181. distributed_backend: Some(crate::config::DistributedBackend::Mp),
  182. before_serve: vec![],
  183. after_stop: vec![],
  184. solo: None,
  185. deploy: crate::config::DeployTarget::Docker,
  186. venv: None,
  187. venv_setup: VenvSetup::default(),
  188. venv_setup_script: None,
  189. mods: Default::default(),
  190. sync_image: true,
  191. docker_image_dir: None,
  192. shm_size: String::new(),
  193. devices: vec![],
  194. memory_limit: None,
  195. cpus: None,
  196. extra_mounts: vec![],
  197. extra_mounts_manifest: None,
  198. build: Default::default(),
  199. composed_model: None,
  200. staging: vec![],
  201. dgpp: None,
  202. }
  203. }
  204. // ── Deserialization tests (the ones that live in this module) ──
  205. #[test]
  206. fn parses_cluster_level_before_serve() {
  207. let toml_str = r#"
  208. ssh_user = "u"
  209. ssh_key_path = "/k"
  210. [[clusters]]
  211. name = "c1"
  212. master_ib_ip = "1.1.1.1"
  213. nodes = [
  214. { name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
  215. ]
  216. [before_serve."c1"]
  217. commands = [
  218. "sudo nvidia-smi -lgc 300,2300",
  219. { command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
  220. ]
  221. "#;
  222. let cfg: Config = toml::from_str(toml_str).unwrap();
  223. assert!(cfg.before_serve.contains_key("c1"));
  224. let list = &cfg.before_serve["c1"].commands;
  225. assert_eq!(list.len(), 2);
  226. assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
  227. assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
  228. assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
  229. assert_eq!(list[1].on_failure(), OnFailure::Warn);
  230. }
  231. #[test]
  232. fn parses_recipe_level_before_serve() {
  233. let toml_str = r#"
  234. name = "Demo"
  235. model = "x"
  236. before_serve = [
  237. "echo a",
  238. { command = "echo b", on_failure = "continue" },
  239. ]
  240. "#;
  241. let r: Recipe = toml::from_str(toml_str).unwrap();
  242. assert_eq!(r.before_serve.len(), 2);
  243. assert_eq!(r.before_serve[0].command(), "echo a");
  244. assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
  245. assert_eq!(r.before_serve[1].command(), "echo b");
  246. assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
  247. }
  248. /// A recipe-relative hook script is read from the recipe dir; absolute
  249. /// paths and `..` traversal are refused (a synced recipe repo must not be
  250. /// able to run an arbitrary file on a node).
  251. #[tokio::test]
  252. async fn recipe_scripts_resolve_inside_the_recipe_dir() {
  253. let dir = std::env::temp_dir().join(format!("sm121-hook-{}", std::process::id()));
  254. tokio::fs::create_dir_all(dir.join("scripts"))
  255. .await
  256. .unwrap();
  257. tokio::fs::write(dir.join("scripts/prepare.sh"), "echo hi\n")
  258. .await
  259. .unwrap();
  260. let body = read_recipe_script("scripts/prepare.sh", Some(&dir), "n0")
  261. .await
  262. .expect("recipe script reads");
  263. assert_eq!(body, "echo hi\n");
  264. let abs = read_recipe_script("/etc/passwd", Some(&dir), "n0").await;
  265. assert!(abs.is_err(), "absolute paths are refused");
  266. let trav = read_recipe_script("scripts/../../etc/passwd", Some(&dir), "n0").await;
  267. assert!(trav.is_err(), "`..` traversal is refused");
  268. let missing = read_recipe_script("scripts/nope.sh", Some(&dir), "n0").await;
  269. assert!(missing.is_err(), "missing file names the built-in aliases");
  270. let no_dir = read_recipe_script("scripts/prepare.sh", None, "n0").await;
  271. assert!(no_dir.is_err(), "unresolvable recipe dir is an error");
  272. let _ = tokio::fs::remove_dir_all(&dir).await;
  273. }
  274. /// A built-in alias wins over a same-named file in recipe sources.
  275. #[test]
  276. fn builtin_aliases_resolve_before_recipe_files() {
  277. assert!(crate::builtin_scripts::resolve("stop_dgx_services").is_some());
  278. assert!(crate::builtin_scripts::resolve("scripts/prepare.sh").is_none());
  279. }