command_tests.rs 9.9 KB

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