command_tests.rs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. use super::*;
  2. use crate::config::{
  3. Cluster, DeployTarget, DistributedBackend, EnvValue, ExtraMount, NcclConfig, Node, Recipe,
  4. };
  5. use crate::pre_serve::{RecipeArgs, VenvSetup};
  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)
  71. .unwrap()
  72. .join(" ")
  73. }
  74. fn base_recipe() -> Recipe {
  75. Recipe {
  76. build_profile: None,
  77. engine: "vllm".into(),
  78. name: "T".into(),
  79. docker_image: "img".into(),
  80. model: "mdl".into(),
  81. port: 8000,
  82. required_nodes: Some(2),
  83. pipeline_parallel_size: Some(2),
  84. tensor_parallel_size: 1,
  85. args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
  86. env: HashMap::new(),
  87. cluster: None,
  88. entrypoint: vec![],
  89. distributed_backend: Some(DistributedBackend::Mp),
  90. before_serve: vec![],
  91. after_stop: vec![],
  92. solo: None,
  93. deploy: DeployTarget::Docker,
  94. venv: None,
  95. venv_setup: VenvSetup::default(),
  96. venv_setup_script: None,
  97. mods: Default::default(),
  98. shm_size: String::new(),
  99. sync_image: true,
  100. docker_image_dir: None,
  101. extra_mounts: vec![],
  102. build: Default::default(),
  103. composed_model: None,
  104. }
  105. }
  106. #[test]
  107. fn env_ref_resolves_to_recipe_port() {
  108. let mut r = base_recipe();
  109. r.port = 8123;
  110. r.env = HashMap::from([
  111. (
  112. "PORT".into(),
  113. crate::config::EnvValue::Ref {
  114. from: "port".into(),
  115. },
  116. ),
  117. ("PLAIN".into(), crate::config::EnvValue::Plain("x".into())),
  118. ]);
  119. let env = r.resolved_env().unwrap();
  120. assert_eq!(env["PORT"], "8123");
  121. assert_eq!(env["PLAIN"], "x");
  122. }
  123. #[test]
  124. fn env_ref_unknown_target_errors() {
  125. let mut r = base_recipe();
  126. r.env = HashMap::from([(
  127. "K".into(),
  128. crate::config::EnvValue::Ref {
  129. from: "bogus".into(),
  130. },
  131. )]);
  132. let err = r.resolved_env().unwrap_err().to_string();
  133. assert!(err.contains("bogus"), "err: {err}");
  134. assert!(err.contains("K"), "err: {err}");
  135. }
  136. #[test]
  137. fn env_toml_parses_mixed_plain_and_ref() {
  138. let r: Recipe = toml::from_str(
  139. r#"
  140. engine = "vllm"
  141. name = "T"
  142. docker_image = "img"
  143. model = "mdl"
  144. port = 31337
  145. [env]
  146. A = "1"
  147. PORT = { from = "port" }
  148. "#,
  149. )
  150. .unwrap();
  151. assert_eq!(r.env.len(), 2);
  152. let env = r.resolved_env().unwrap();
  153. assert_eq!(env["PORT"], "31337");
  154. assert_eq!(env["A"], "1");
  155. }
  156. #[test]
  157. fn env_ref_renders_in_docker_env() {
  158. let mut r = base_recipe();
  159. r.port = 39999;
  160. r.env = HashMap::from([(
  161. "PORT".into(),
  162. crate::config::EnvValue::Ref {
  163. from: "port".into(),
  164. },
  165. )]);
  166. let cmd = run_with(&r);
  167. assert!(cmd.contains("-e 'PORT=39999'"), "cmd: {cmd}");
  168. }
  169. #[test]
  170. fn extra_mounts_are_mounted() {
  171. let mut r = base_recipe();
  172. r.extra_mounts.push(ExtraMount {
  173. alias: Some("models".into()),
  174. host: Some("~/.cache/models".into()),
  175. repo: None,
  176. subfolder: None,
  177. container: "/models".into(),
  178. read_only: true,
  179. env: Some("MODEL_DIR".into()),
  180. });
  181. let cmd = run_with(&r);
  182. // alias present in minimal_config() → alias host wins. Host/container
  183. // and env are now shell-quoted so a crafted value can't inject.
  184. assert!(cmd.contains("-v '/mnt/nas/models':'/models':ro"));
  185. assert!(cmd.contains("-v '/home/user/.cache/huggingface':/root/.cache/huggingface"));
  186. assert!(!cmd.contains("-v /mnt/nas/models:/models:ro")); // unquoted form gone
  187. // env injected pointing at the container path, before recipe env
  188. assert!(cmd.contains("-e 'MODEL_DIR=/models'"));
  189. }
  190. #[test]
  191. fn extra_mounts_fallback_to_host_when_alias_absent() {
  192. let mut r = base_recipe();
  193. r.extra_mounts.push(ExtraMount {
  194. alias: Some("no_such_alias".into()),
  195. host: Some("~/.cache/jit".into()),
  196. repo: None,
  197. subfolder: None,
  198. container: "/root/.cache/jit".into(),
  199. read_only: false,
  200. env: None,
  201. });
  202. let cmd = run_with(&r);
  203. // alias not in config → host default used with ~ expansion (ssh_user=u)
  204. assert!(cmd.contains("-v '/home/u/.cache/jit':'/root/.cache/jit'"));
  205. assert!(!cmd.contains(":ro"));
  206. }
  207. #[test]
  208. fn malicious_env_value_cannot_inject() {
  209. // A crafted `[env]` value with shell metachars must stay a single
  210. // quoted argument — never break out of the remote `docker run`.
  211. let mut r = base_recipe();
  212. r.env
  213. .insert("EVIL".to_string(), "x; touch /tmp/pwned".into());
  214. let cmd = run_with(&r);
  215. assert!(cmd.contains("-e 'EVIL=x; touch /tmp/pwned'"));
  216. // No raw `; touch` escape: the whole K=V is inside quotes.
  217. assert!(!cmd.contains("-e EVIL=x;"));
  218. }
  219. #[test]
  220. fn malicious_mount_container_cannot_inject() {
  221. let mut r = base_recipe();
  222. r.extra_mounts.push(ExtraMount {
  223. alias: None,
  224. host: Some("/safe".into()),
  225. repo: None,
  226. subfolder: None,
  227. container: "/x; touch /tmp/pwned".into(),
  228. read_only: false,
  229. env: None,
  230. });
  231. let cmd = run_with(&r);
  232. // Container path is quoted; the metacharacter is inert inside quotes.
  233. assert!(cmd.contains("'/x; touch /tmp/pwned'"));
  234. // Never appear as the unquoted concatenation `-v /safe:/x; touch`.
  235. assert!(!cmd.contains("-v /safe:/x; touch"));
  236. }
  237. #[test]
  238. fn model_path_ref_is_partitioned_not_mounted() {
  239. // repo-based mounts are path references, NOT bind-mounts. They are
  240. // partitioned into path_refs and resolved per-node via substitution.
  241. let mut r = base_recipe();
  242. r.extra_mounts.push(ExtraMount {
  243. alias: None,
  244. host: None,
  245. repo: Some("aidendle94/GLM-5.2-Hybrid-FP8-MXPF4".into()),
  246. subfolder: Some("mtp-draft".into()),
  247. container: "mtp_draft".into(),
  248. read_only: true,
  249. env: None,
  250. });
  251. let resolved = resolve_extra_mounts(&minimal_config(), &r).unwrap();
  252. assert_eq!(resolved.path_refs.len(), 1);
  253. assert_eq!(resolved.static_mounts.len(), 0);
  254. let pr = &resolved.path_refs[0];
  255. assert_eq!(pr.repo, "aidendle94/GLM-5.2-Hybrid-FP8-MXPF4");
  256. assert_eq!(pr.subfolder.as_deref(), Some("mtp-draft"));
  257. assert_eq!(pr.name, "mtp_draft");
  258. // No -v flag for path refs in build_docker_command (substitution only)
  259. let cmd = run_with(&r);
  260. assert!(!cmd.contains("-v /models/mtp-draft"));
  261. }
  262. #[test]
  263. fn substitute_path_refs_replaces_placeholders() {
  264. let mut r = base_recipe();
  265. r.args = RecipeArgs::List(vec![
  266. "--speculative-config".into(),
  267. "{\"model\":\"{{mtp_draft}}\"}".into(),
  268. ]);
  269. let path_refs = vec![(
  270. "mtp_draft".to_string(),
  271. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
  272. )];
  273. let substituted = substitute_path_refs(&r, &path_refs);
  274. if let RecipeArgs::List(args) = &substituted.args {
  275. assert_eq!(
  276. args[1],
  277. "{\"model\":\"/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft\"}"
  278. );
  279. } else {
  280. panic!("expected list args");
  281. }
  282. }
  283. #[test]
  284. fn substitute_path_refs_in_env() {
  285. let mut r = base_recipe();
  286. r.env.insert("MTP_PATH".to_string(), "{{mtp_draft}}".into());
  287. let path_refs = vec![(
  288. "mtp_draft".to_string(),
  289. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string(),
  290. )];
  291. let substituted = substitute_path_refs(&r, &path_refs);
  292. assert_eq!(
  293. substituted.env["MTP_PATH"],
  294. EnvValue::Plain(
  295. "/root/.cache/huggingface/hub/models--aidendle94--GLM-5.2-Hybrid-FP8-MXPF4/snapshots/bb0d0858/mtp-draft".to_string()
  296. )
  297. );
  298. }
  299. #[test]
  300. fn clear_paths_dedups_and_sorts() {
  301. let m = |host: &str| ResolvedMount {
  302. host: host.into(),
  303. container: "/c".into(),
  304. read_only: false,
  305. env: None,
  306. };
  307. let mounts = vec![m("/b/.cache/jit"), m("/a/.cache/jit"), m("/b/.cache/jit")];
  308. assert_eq!(clear_paths(&mounts), vec!["/a/.cache/jit", "/b/.cache/jit"]);
  309. assert!(clear_paths(&[]).is_empty());
  310. }