tests.rs 16 KB

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