command_tests.rs 7.7 KB

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