tests.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. use super::command::{DockerCommandCtx, build_docker_command};
  2. use super::*;
  3. use crate::config::{DeployTarget, DistributedBackend, RecipeArgs, VenvSetup};
  4. use std::collections::HashMap;
  5. fn tcls() -> Cluster {
  6. Cluster {
  7. name: "t".into(),
  8. master_ib_ip: Some("1".into()),
  9. nodes: vec![
  10. Node {
  11. name: "n0".into(),
  12. ssh_ip: "10.0.0.1".into(),
  13. ib_ip: Some("1".into()),
  14. rank: 0,
  15. host_key: None,
  16. entity: None,
  17. },
  18. Node {
  19. name: "n1".into(),
  20. ssh_ip: "10.0.0.2".into(),
  21. ib_ip: Some("2".into()),
  22. rank: 1,
  23. host_key: None,
  24. entity: None,
  25. },
  26. ],
  27. extra_entities: vec![],
  28. }
  29. }
  30. fn tnccl() -> NcclConfig {
  31. NcclConfig {
  32. ib_hca: "r".into(),
  33. socket_ifname: "e".into(),
  34. ib_gid_index: Some(3),
  35. ib_disable: 0,
  36. gloo_socket_ifname: None,
  37. ucx_net_devices: None,
  38. nccl_cross_nic: None,
  39. nccl_proto: None,
  40. nccl_p2p_level: None,
  41. }
  42. }
  43. fn trec() -> Recipe {
  44. Recipe {
  45. engine: "vllm".into(),
  46. name: "T".into(),
  47. docker_image: "img".into(),
  48. model: "mdl".into(),
  49. port: 8000,
  50. required_nodes: Some(2),
  51. pipeline_parallel_size: Some(2),
  52. tensor_parallel_size: 1,
  53. args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
  54. env: HashMap::from([("K".into(), "V".into())]),
  55. cluster: None,
  56. entrypoint: vec![],
  57. distributed_backend: Some(DistributedBackend::Mp),
  58. before_serve: vec![],
  59. after_stop: vec![],
  60. solo: None,
  61. deploy: DeployTarget::Docker,
  62. venv: None,
  63. venv_setup: VenvSetup::default(),
  64. venv_setup_script: None,
  65. mods: Default::default(),
  66. shm_size: String::new(),
  67. sync_image: true,
  68. docker_image_dir: None,
  69. extra_mounts: vec![],
  70. build: Default::default(),
  71. composed_model: None,
  72. }
  73. }
  74. fn make_dm_ctx(recipe: &Recipe, nnodes: u32, pp: u32, tp: u32, keep: bool) -> DockerCommandCtx {
  75. DockerCommandCtx {
  76. cluster: tcls(),
  77. nccl: Some(tnccl()),
  78. recipe: recipe.clone(),
  79. recipe_name: "r".to_string(),
  80. pp_size: pp,
  81. tp_size: tp,
  82. nnodes,
  83. cache_dir: "/home/user/.cache/huggingface".to_string(),
  84. keep,
  85. init_port: 25000,
  86. mods_remote_path: String::new(),
  87. extra_mounts: vec![],
  88. }
  89. }
  90. /// Remove the single-quote wrapping `shq` adds, so existing assertions that
  91. /// check the un-quoted command shape keep working. Quoting itself is asserted
  92. /// separately (see `malicious_env_value_cannot_inject` etc.).
  93. fn squash(s: &str) -> String {
  94. s.replace('\'', "")
  95. }
  96. fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
  97. let tp = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  98. 2
  99. } else {
  100. recipe.tensor_parallel_size
  101. };
  102. squash(&build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, false), node, head).join(" "))
  103. }
  104. #[test]
  105. fn nccl_gid_unresolved_renders_auto() {
  106. // A cluster [nccl] block without `ib_gid_index` (auto-detect) renders
  107. // `NCCL_IB_GID_INDEX=auto` — the launcher resolves the real index per
  108. // node before the docker run; `auto` only appears on the dry-run/unresolved
  109. // path. Confirms the env is emitted and clearly marked, not dropped.
  110. let mut nccl = tnccl();
  111. nccl.ib_gid_index = None;
  112. let mut ctx = make_dm_ctx(&trec(), 2, 2, 2, false);
  113. ctx.nccl = Some(nccl);
  114. let cmd = squash(&build_docker_command(&ctx, &tcls().nodes[0], true).join(" "));
  115. assert!(
  116. cmd.contains("-e NCCL_IB_GID_INDEX=auto"),
  117. "unresolved GID should render 'auto' placeholder: {cmd}"
  118. );
  119. }
  120. fn run_keep(recipe: &Recipe, node: &Node, head: bool) -> String {
  121. let tp = if recipe.required_nodes == Some(recipe.tensor_parallel_size) {
  122. 2
  123. } else {
  124. recipe.tensor_parallel_size
  125. };
  126. squash(&build_docker_command(&make_dm_ctx(recipe, 2, 2, tp, true), node, head).join(" "))
  127. }
  128. #[test]
  129. fn mp() {
  130. let cmd = run(&trec(), &tcls().nodes[0], true);
  131. assert!(cmd.contains("docker run -d --gpus all"));
  132. assert!(cmd.contains("-e NCCL_IB_HCA=r"));
  133. assert!(cmd.contains("-e K=V"));
  134. assert!(cmd.contains("--name sm121_r_0"));
  135. assert!(
  136. !cmd.contains("-p "),
  137. "host networking: no port mapping expected"
  138. );
  139. assert!(cmd.contains("--label sm121.recipe=r"));
  140. assert!(cmd.contains("--label sm121.rank=0"));
  141. assert!(cmd.contains("img"));
  142. assert!(cmd.contains("mdl"));
  143. assert!(cmd.contains("--tensor-parallel-size 1"));
  144. assert!(cmd.contains("--pipeline-parallel-size 2"));
  145. assert!(cmd.contains("--nnodes 2"));
  146. assert!(cmd.contains("--node-rank 0"));
  147. assert!(cmd.contains("--master-addr 1"));
  148. assert!(cmd.contains("--master-port 25000"));
  149. // vLLM auto-detects mp; only explicitly pass for ray
  150. assert!(
  151. !cmd.contains("--distributed-executor-backend"),
  152. "mp backend should be auto-detected"
  153. );
  154. assert!(!cmd.contains("RAY_ADDRESS"));
  155. assert!(cmd.contains("--rm"), "default --rm should be present");
  156. // New: defaults & cluster env
  157. assert!(
  158. !cmd.contains("--shm-size"),
  159. "shm-size should not be set by default"
  160. );
  161. assert!(cmd.contains("--ulimit memlock=-1"));
  162. assert!(cmd.contains("--ulimit stack=67108864"));
  163. assert!(
  164. cmd.contains("-e GLOO_SOCKET_IFNAME=e"),
  165. "default GLOO_SOCKET_IFNAME missing"
  166. );
  167. assert!(
  168. cmd.contains("-e TP_SOCKET_IFNAME=e"),
  169. "default TP_SOCKET_IFNAME missing"
  170. );
  171. assert!(
  172. cmd.contains("-e OMP_NUM_THREADS=4"),
  173. "default OMP_NUM_THREADS missing"
  174. );
  175. assert!(
  176. cmd.contains("-e NCCL_IGNORE_CPU_AFFINITY=1"),
  177. "default NCCL_IGNORE_CPU_AFFINITY missing"
  178. );
  179. assert!(cmd.contains("-e NCCL_NET=IB"), "default NCCL_NET missing");
  180. assert!(
  181. cmd.contains("-e NCCL_DEBUG=WARN"),
  182. "default NCCL_DEBUG missing"
  183. );
  184. assert!(
  185. cmd.contains("-e MN_IF_NAME=e"),
  186. "cluster MN_IF_NAME missing"
  187. );
  188. assert!(
  189. cmd.contains("-e UCX_NET_DEVICES=e"),
  190. "cluster UCX_NET_DEVICES missing"
  191. );
  192. assert!(
  193. cmd.contains("-e OMPI_MCA_btl_tcp_if_include=e"),
  194. "cluster OMPI_MCA_btl_tcp_if_include missing"
  195. );
  196. // Head node: no --headless
  197. assert!(!cmd.contains("--headless"));
  198. }
  199. #[test]
  200. fn worker_no_port() {
  201. let cmd = run(&trec(), &tcls().nodes[1], false);
  202. assert!(!cmd.contains("-p 8000:8000"));
  203. assert!(cmd.contains("--node-rank 1"));
  204. assert!(cmd.contains("--label sm121.rank=1"));
  205. assert!(cmd.contains("--name sm121_r_1"));
  206. // New: vLLM workers run --headless
  207. assert!(cmd.contains("--headless"), "vLLM worker should be headless");
  208. }
  209. #[test]
  210. fn ray_backend() {
  211. let mut r = trec();
  212. r.distributed_backend = Some(DistributedBackend::Ray);
  213. let cmd = run(&r, &tcls().nodes[0], true);
  214. assert!(cmd.contains("-e RAY_ADDRESS=1:6379"));
  215. assert!(cmd.contains("-e RAY_PORT=6379"));
  216. assert!(cmd.contains("--node-rank 0"));
  217. assert!(cmd.contains("--distributed-executor-backend ray"));
  218. }
  219. #[test]
  220. fn custom_tensor_parallel_size() {
  221. // Per-node TP=1 is the default. Custom TP (e.g. multi-node TP)
  222. // can be set via recipe.tensor_parallel_size.
  223. let mut r = trec();
  224. r.tensor_parallel_size = 2;
  225. let cmd = run(&r, &tcls().nodes[0], true);
  226. assert!(cmd.contains("--tensor-parallel-size 2"), "custom TP");
  227. }
  228. #[test]
  229. fn auto_pp_size() {
  230. let cmd = run(&trec(), &tcls().nodes[0], true);
  231. assert!(cmd.contains("--pipeline-parallel-size 2"));
  232. }
  233. #[test]
  234. fn entrypoint() {
  235. let mut r = trec();
  236. r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
  237. let cmd = run(&r, &tcls().nodes[0], true);
  238. let ip = cmd.find("img").unwrap();
  239. let mp = cmd.find("mdl").unwrap();
  240. let ep = cmd.find("py -m mod").unwrap();
  241. assert!(
  242. ip < ep && ep < mp,
  243. "entrypoint should be between image and model"
  244. );
  245. }
  246. #[test]
  247. fn keep_disables_rm() {
  248. let cmd = run_keep(&trec(), &tcls().nodes[0], true);
  249. assert!(!cmd.contains("--rm"), "--keep should suppress --rm");
  250. assert!(cmd.contains("docker run -d"));
  251. assert!(cmd.contains("--gpus all"));
  252. assert!(cmd.contains("-e HF_HOME=/root/.cache/huggingface"));
  253. assert!(cmd.contains("-v /home/user/.cache/huggingface:/root/.cache/huggingface"));
  254. }
  255. #[test]
  256. fn recipe_env_overrides_cluster_defaults() {
  257. // Recipe-level env should win over the defaults we inject.
  258. let mut r = trec();
  259. r.env.insert("NCCL_CUMEM_ENABLE".into(), "1".into());
  260. r.env.insert("OMP_NUM_THREADS".into(), "16".into());
  261. r.env.insert("NCCL_IGNORE_CPU_AFFINITY".into(), "0".into());
  262. r.env.insert("NCCL_NET".into(), "SOCKET".into());
  263. r.env.insert("NCCL_CROSS_NIC".into(), "0".into());
  264. r.env.insert("NCCL_DEBUG".into(), "INFO".into());
  265. let cmd = run(&r, &tcls().nodes[0], true);
  266. // Docker uses the last -e for duplicate keys, so recipe values come after.
  267. // rfind returns the last occurrence, which should be the recipe override.
  268. let cumem_pos = cmd.rfind("NCCL_CUMEM_ENABLE=").unwrap();
  269. assert!(
  270. cmd[cumem_pos..].starts_with("NCCL_CUMEM_ENABLE=1"),
  271. "recipe override should be last: slice starts at {}",
  272. cumem_pos
  273. );
  274. let omp_pos = cmd.rfind("OMP_NUM_THREADS=").unwrap();
  275. assert!(
  276. cmd[omp_pos..].starts_with("OMP_NUM_THREADS=16"),
  277. "recipe override should be last"
  278. );
  279. // New NCCL defaults can also be overridden.
  280. let ignore_pos = cmd.rfind("NCCL_IGNORE_CPU_AFFINITY=").unwrap();
  281. assert!(
  282. cmd[ignore_pos..].starts_with("NCCL_IGNORE_CPU_AFFINITY=0"),
  283. "NCCL_IGNORE_CPU_AFFINITY should be overridable"
  284. );
  285. let net_pos = cmd.rfind("NCCL_NET=").unwrap();
  286. assert!(
  287. cmd[net_pos..].starts_with("NCCL_NET=SOCKET"),
  288. "NCCL_NET should be overridable"
  289. );
  290. let cross_pos = cmd.rfind("NCCL_CROSS_NIC=").unwrap();
  291. assert!(
  292. cmd[cross_pos..].starts_with("NCCL_CROSS_NIC=0"),
  293. "NCCL_CROSS_NIC should be overridable"
  294. );
  295. let debug_pos = cmd.rfind("NCCL_DEBUG=").unwrap();
  296. assert!(
  297. cmd[debug_pos..].starts_with("NCCL_DEBUG=INFO"),
  298. "NCCL_DEBUG should be overridable"
  299. );
  300. }
  301. #[test]
  302. fn custom_init_port() {
  303. // Non-default init_port should propagate to vLLM master-port and
  304. // SGLang dist-init-addr.
  305. let r = trec();
  306. let tp = if r.required_nodes == Some(r.tensor_parallel_size) {
  307. 2
  308. } else {
  309. r.tensor_parallel_size
  310. };
  311. let mut ctx = make_dm_ctx(&r, 2, 2, tp, false);
  312. ctx.init_port = 29500;
  313. let cmd = squash(&build_docker_command(&ctx, &tcls().nodes[0], true).join(" "));
  314. assert!(cmd.contains("--master-port 29500"));
  315. assert!(cmd.contains("--master-addr 1"));
  316. }
  317. // ── Logs command format ───────────────────────────────────────
  318. #[test]
  319. fn logs_tail_cmd_format() {
  320. let cmd = super::logs::logs_tail_cmd("test-sleep", 0, 100);
  321. assert!(cmd.contains("docker ps -q"));
  322. assert!(cmd.contains("--filter 'label=sm121.recipe=test-sleep'"));
  323. assert!(cmd.contains("--filter label=sm121.rank=0"));
  324. assert!(cmd.contains("xargs -r docker logs"));
  325. assert!(cmd.contains("--timestamps"));
  326. assert!(cmd.contains("--tail=100"));
  327. assert!(cmd.ends_with(" 2>&1"));
  328. }
  329. #[test]
  330. fn logs_tail_cmd_per_rank() {
  331. let cmd0 = super::logs::logs_tail_cmd("glm51", 0, 100);
  332. let cmd2 = super::logs::logs_tail_cmd("glm51", 2, 100);
  333. assert!(cmd0.contains("label=sm121.rank=0"));
  334. assert!(cmd2.contains("label=sm121.rank=2"));
  335. assert!(!cmd0.contains("label=sm121.rank=2"));
  336. }
  337. #[test]
  338. fn logs_follow_cmd_format() {
  339. let cmd = super::logs::logs_follow_cmd("test-sleep");
  340. assert!(cmd.contains("xargs -r docker logs -f"));
  341. assert!(cmd.contains("--timestamps"));
  342. // Follow targets the head node only (rank=0).
  343. assert!(cmd.contains("--filter label=sm121.rank=0"));
  344. // Follow should not include --tail (we want a live stream from now).
  345. assert!(!cmd.contains("--tail"));
  346. // Follow must redirect stderr so docker's own errors are captured
  347. // (mirrors the behaviour of logs_tail_cmd).
  348. assert!(
  349. cmd.contains("2>&1"),
  350. "follow cmd must capture stderr, got: {cmd}"
  351. );
  352. }
  353. // ── Shell quoting ─────────────────────────────────────────────
  354. #[test]
  355. fn shq_wraps_simple_strings() {
  356. assert_eq!(shq("foo"), "'foo'");
  357. assert_eq!(shq(""), "''");
  358. assert_eq!(shq("foo bar"), "'foo bar'");
  359. }
  360. #[test]
  361. fn shq_escapes_inner_single_quotes() {
  362. // "don't" → 'don'\''t'
  363. assert_eq!(shq("don't"), "'don'\\''t'");
  364. }
  365. #[test]
  366. fn entrypoint_script_is_shell_quoted() {
  367. // Every tail word — incl. the `sh -c` script — is shell-quoted.
  368. let mut r = trec();
  369. r.entrypoint = vec!["sh".into(), "-c".into(), "echo $i; sleep 1".into()];
  370. let cmd =
  371. build_docker_command(&make_dm_ctx(&r, 2, 2, 2, false), &tcls().nodes[0], true).join(" ");
  372. assert!(
  373. cmd.contains("'echo $i; sleep 1'"),
  374. "must be shell-quoted: {cmd}"
  375. );
  376. }
  377. #[test]
  378. fn entrypoint_script_non_sh_passes_through() {
  379. // For non `sh -c` entrypoints (e.g. ["sh", "-c", "...", "extra"]) or
  380. // direct binaries, we leave the args alone — no false positive shq.
  381. let mut r = trec();
  382. r.entrypoint = vec!["sh".into(), "-c".into()];
  383. let cmd = run(&r, &tcls().nodes[0], true);
  384. assert!(cmd.contains("docker run"));
  385. }
  386. #[path = "solo_tests.rs"]
  387. mod solo_tests;
  388. #[path = "sglang_tests.rs"]
  389. mod sglang_tests;