command_tests.rs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. use super::*;
  2. use crate::config::{
  3. Cluster, DeployTarget, DistributedBackend, ExtraMount, NcclConfig, Node, Recipe, RecipeArgs,
  4. VenvSetup,
  5. };
  6. use std::collections::HashMap;
  7. fn tcls() -> Cluster {
  8. Cluster {
  9. name: "t".into(),
  10. master_ib_ip: Some("1".into()),
  11. nodes: vec![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. extra_entities: vec![],
  20. }
  21. }
  22. fn tnccl() -> NcclConfig {
  23. NcclConfig {
  24. ib_hca: "r".into(),
  25. socket_ifname: "e".into(),
  26. ib_gid_index: Some(3),
  27. ib_disable: 0,
  28. gloo_socket_ifname: None,
  29. ucx_net_devices: None,
  30. nccl_cross_nic: None,
  31. nccl_proto: None,
  32. nccl_p2p_level: None,
  33. }
  34. }
  35. fn minimal_config() -> Config {
  36. Config {
  37. ssh_user: "u".into(),
  38. ssh_key_path: "/tmp/key".into(),
  39. clusters: vec![],
  40. nccl: HashMap::new(),
  41. hf_cache_dir: None,
  42. init_port: 25000,
  43. before_serve: HashMap::new(),
  44. exec_aliases: HashMap::new(),
  45. home_assistant: None,
  46. global_mods_dir: None,
  47. drop_caches: true,
  48. mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),
  49. recipe_sources: Vec::new(),
  50. docker_image_sources: Vec::new(),
  51. cluster_sources: Vec::new(),
  52. }
  53. }
  54. fn run_with(recipe: &Recipe) -> String {
  55. let resolved = resolve_extra_mounts(&minimal_config(), recipe).unwrap();
  56. let ctx = DockerCommandCtx {
  57. cluster: tcls(),
  58. nccl: Some(tnccl()),
  59. recipe: recipe.clone(),
  60. recipe_name: "r".to_string(),
  61. pp_size: 2,
  62. tp_size: 1,
  63. nnodes: 2,
  64. cache_dir: "/home/user/.cache/huggingface".to_string(),
  65. keep: false,
  66. init_port: 25000,
  67. mods_remote_path: String::new(),
  68. extra_mounts: resolved.static_mounts,
  69. };
  70. build_docker_command(&ctx, &tcls().nodes[0], true).join(" ")
  71. }
  72. fn base_recipe() -> Recipe {
  73. Recipe {
  74. engine: "vllm".into(),
  75. name: "T".into(),
  76. docker_image: "img".into(),
  77. model: "mdl".into(),
  78. port: 8000,
  79. required_nodes: Some(2),
  80. pipeline_parallel_size: Some(2),
  81. tensor_parallel_size: 1,
  82. args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
  83. env: HashMap::new(),
  84. cluster: None,
  85. entrypoint: vec![],
  86. distributed_backend: Some(DistributedBackend::Mp),
  87. before_serve: vec![],
  88. after_stop: vec![],
  89. solo: None,
  90. deploy: DeployTarget::Docker,
  91. venv: None,
  92. venv_setup: VenvSetup::default(),
  93. venv_setup_script: None,
  94. mods: Default::default(),
  95. shm_size: String::new(),
  96. sync_image: true,
  97. docker_image_dir: None,
  98. extra_mounts: vec![],
  99. build: Default::default(),
  100. composed_model: None,
  101. }
  102. }
  103. #[test]
  104. fn extra_mounts_are_mounted() {
  105. let mut r = base_recipe();
  106. r.extra_mounts.push(ExtraMount {
  107. alias: Some("models".into()),
  108. host: Some("~/.cache/models".into()),
  109. repo: None,
  110. subfolder: None,
  111. container: "/models".into(),
  112. read_only: true,
  113. env: Some("MODEL_DIR".into()),
  114. });
  115. let cmd = run_with(&r);
  116. // alias present in minimal_config() → alias host wins. Host/container
  117. // and env are now shell-quoted so a crafted value can't inject.
  118. assert!(cmd.contains("-v '/mnt/nas/models':'/models':ro"));
  119. assert!(cmd.contains("-v '/home/user/.cache/huggingface':/root/.cache/huggingface"));
  120. assert!(!cmd.contains("-v /mnt/nas/models:/models:ro")); // unquoted form gone
  121. // env injected pointing at the container path, before recipe env
  122. assert!(cmd.contains("-e 'MODEL_DIR=/models'"));
  123. }
  124. #[test]
  125. fn extra_mounts_fallback_to_host_when_alias_absent() {
  126. let mut r = base_recipe();
  127. r.extra_mounts.push(ExtraMount {
  128. alias: Some("no_such_alias".into()),
  129. host: Some("~/.cache/jit".into()),
  130. repo: None,
  131. subfolder: None,
  132. container: "/root/.cache/jit".into(),
  133. read_only: false,
  134. env: None,
  135. });
  136. let cmd = run_with(&r);
  137. // alias not in config → host default used with ~ expansion (ssh_user=u)
  138. assert!(cmd.contains("-v '/home/u/.cache/jit':'/root/.cache/jit'"));
  139. assert!(!cmd.contains(":ro"));
  140. }
  141. #[test]
  142. fn malicious_env_value_cannot_inject() {
  143. // A crafted `[env]` value with shell metachars must stay a single
  144. // quoted argument — never break out of the remote `docker run`.
  145. let mut r = base_recipe();
  146. r.env
  147. .insert("EVIL".to_string(), "x; touch /tmp/pwned".to_string());
  148. let cmd = run_with(&r);
  149. assert!(cmd.contains("-e 'EVIL=x; touch /tmp/pwned'"));
  150. // No raw `; touch` escape: the whole K=V is inside quotes.
  151. assert!(!cmd.contains("-e EVIL=x;"));
  152. }
  153. #[test]
  154. fn malicious_mount_container_cannot_inject() {
  155. let mut r = base_recipe();
  156. r.extra_mounts.push(ExtraMount {
  157. alias: None,
  158. host: Some("/safe".into()),
  159. repo: None,
  160. subfolder: None,
  161. container: "/x; touch /tmp/pwned".into(),
  162. read_only: false,
  163. env: None,
  164. });
  165. let cmd = run_with(&r);
  166. // Container path is quoted; the metacharacter is inert inside quotes.
  167. assert!(cmd.contains("'/x; touch /tmp/pwned'"));
  168. // Never appear as the unquoted concatenation `-v /safe:/x; touch`.
  169. assert!(!cmd.contains("-v /safe:/x; touch"));
  170. }
  171. #[test]
  172. fn model_path_ref_is_partitioned_not_mounted() {
  173. // repo-based mounts are path references, NOT bind-mounts. They are
  174. // partitioned into path_refs and resolved per-node via substitution.
  175. let mut r = base_recipe();
  176. r.extra_mounts.push(ExtraMount {
  177. alias: None,
  178. host: None,
  179. repo: Some("aidendle94/GLM-5.2-Hybrid-FP8-MXPF4".into()),
  180. subfolder: Some("mtp-draft".into()),
  181. container: "mtp_draft".into(),
  182. read_only: true,
  183. env: None,
  184. });
  185. let resolved = resolve_extra_mounts(&minimal_config(), &r).unwrap();
  186. assert_eq!(resolved.path_refs.len(), 1);
  187. assert_eq!(resolved.static_mounts.len(), 0);
  188. let pr = &resolved.path_refs[0];
  189. assert_eq!(pr.repo, "aidendle94/GLM-5.2-Hybrid-FP8-MXPF4");
  190. assert_eq!(pr.subfolder.as_deref(), Some("mtp-draft"));
  191. assert_eq!(pr.name, "mtp_draft");
  192. // No -v flag for path refs in build_docker_command (substitution only)
  193. let cmd = run_with(&r);
  194. assert!(!cmd.contains("-v /models/mtp-draft"));
  195. }
  196. #[test]
  197. fn substitute_path_refs_replaces_placeholders() {
  198. let mut r = base_recipe();
  199. r.args = RecipeArgs::List(vec![
  200. "--speculative-config".into(),
  201. "{\"model\":\"{{mtp_draft}}\"}".into(),
  202. ]);
  203. let path_refs = vec![(
  204. "mtp_draft".to_string(),
  205. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
  206. )];
  207. let substituted = substitute_path_refs(&r, &path_refs);
  208. if let RecipeArgs::List(args) = &substituted.args {
  209. assert_eq!(
  210. args[1],
  211. "{\"model\":\"/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft\"}"
  212. );
  213. } else {
  214. panic!("expected list args");
  215. }
  216. }
  217. #[test]
  218. fn substitute_path_refs_in_env() {
  219. let mut r = base_recipe();
  220. r.env
  221. .insert("MTP_PATH".to_string(), "{{mtp_draft}}".to_string());
  222. let path_refs = vec![(
  223. "mtp_draft".to_string(),
  224. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
  225. )];
  226. let substituted = substitute_path_refs(&r, &path_refs);
  227. assert_eq!(
  228. substituted.env["MTP_PATH"],
  229. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft"
  230. );
  231. }
  232. #[test]
  233. fn clear_paths_dedups_and_sorts() {
  234. let m = |host: &str| ResolvedMount {
  235. host: host.into(),
  236. container: "/c".into(),
  237. read_only: false,
  238. env: None,
  239. };
  240. let mounts = vec![m("/b/.cache/jit"), m("/a/.cache/jit"), m("/b/.cache/jit")];
  241. assert_eq!(clear_paths(&mounts), vec!["/a/.cache/jit", "/b/.cache/jit"]);
  242. assert!(clear_paths(&[]).is_empty());
  243. }