tests.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. use crate::config::*;
  2. use crate::pre_serve::*;
  3. use std::collections::{HashMap, HashSet};
  4. fn tcls() -> Cluster {
  5. Cluster {
  6. name: "test-cluster".to_string(),
  7. master_ib_ip: Some("192.168.1.1".to_string()),
  8. nodes: vec![
  9. Node {
  10. name: "node0".to_string(),
  11. ssh_ip: "10.0.0.1".to_string(),
  12. ib_ip: Some("192.168.1.1".to_string()),
  13. rank: 0,
  14. host_key: None,
  15. entity: None,
  16. },
  17. Node {
  18. name: "node1".to_string(),
  19. ssh_ip: "10.0.0.2".to_string(),
  20. ib_ip: Some("192.168.1.2".to_string()),
  21. rank: 1,
  22. host_key: None,
  23. entity: None,
  24. },
  25. ],
  26. extra_entities: vec![],
  27. }
  28. }
  29. fn tcfg() -> Config {
  30. Config {
  31. ssh_user: "u".to_string(),
  32. ssh_key_path: "/k".into(),
  33. clusters: vec![tcls()],
  34. nccl: HashMap::new(),
  35. hf_cache_dir: None,
  36. init_port: 25000,
  37. before_serve: HashMap::new(),
  38. exec_aliases: HashMap::new(),
  39. home_assistant: None,
  40. global_mods_dir: None,
  41. drop_caches: true,
  42. mount_aliases: HashMap::new(),
  43. recipe_sources: Vec::new(),
  44. cluster_sources: Vec::new(),
  45. }
  46. }
  47. fn tnccl() -> NcclConfig {
  48. NcclConfig {
  49. ib_hca: "rocep1s0f0".to_string(),
  50. socket_ifname: "enp1s0f0np0".to_string(),
  51. ib_gid_index: Some(3),
  52. ib_disable: 0,
  53. gloo_socket_ifname: None,
  54. ucx_net_devices: None,
  55. nccl_cross_nic: None,
  56. nccl_proto: None,
  57. nccl_p2p_level: None,
  58. }
  59. }
  60. #[test]
  61. fn test_recipe_deserialize_minimal() {
  62. let r: Recipe = toml::from_str("\nname = \"M\"\nmodel = \"t/m\"\n").unwrap();
  63. assert_eq!(r.name, "M");
  64. assert_eq!(r.engine, "vllm");
  65. assert_eq!(r.port, 30000);
  66. }
  67. #[test]
  68. fn test_recipe_deserialize_full() {
  69. let r: Recipe = toml::from_str("engine = \"sglang\"\nname = \"Full\"\ndocker_image = \"o/i:t\"\nmodel = \"o/m\"\nport = 30000\nrequired_nodes = 4\npipeline_parallel_size = 2\ndistributed_backend = \"ray\"\ncluster = \"c\"\nargs = [\"--a\", \"v\"]\n[env]\nK = \"V\"\n").unwrap();
  70. assert_eq!(r.engine, "sglang");
  71. assert_eq!(r.required_nodes, Some(4));
  72. assert_eq!(r.distributed_backend, Some(DistributedBackend::Ray));
  73. }
  74. #[test]
  75. fn test_recipe_deserialize_no_required_nodes() {
  76. let r: Recipe = toml::from_str("name = \"F\"\nmodel = \"t/m\"\n").unwrap();
  77. assert_eq!(r.required_nodes, None);
  78. assert_eq!(r.pipeline_parallel_size, None);
  79. }
  80. #[test]
  81. fn test_composed_source_exclude_deserialize() {
  82. let toml = r#"
  83. name = "C"
  84. model = "t/m"
  85. [composed_model]
  86. mount = "/root/model"
  87. [[composed_model.sources]]
  88. repo = "base"
  89. [[composed_model.sources]]
  90. repo = "overlay"
  91. glob = "L*.safetensors"
  92. exclude = ["L5.safetensors", "L8.safetensors"]
  93. [[composed_model.files]]
  94. name = "cfg.json"
  95. content = "{}"
  96. "#;
  97. let r: Recipe = toml::from_str(toml).unwrap();
  98. let composed = r.composed_model.unwrap();
  99. assert_eq!(composed.sources.len(), 2);
  100. let overlay = &composed.sources[1];
  101. assert_eq!(overlay.repo, "overlay");
  102. assert_eq!(overlay.glob.as_deref(), Some("L*.safetensors"));
  103. let exclude = overlay
  104. .exclude
  105. .as_ref()
  106. .expect("exclude should deserialize");
  107. assert_eq!(exclude.len(), 2);
  108. assert!(exclude.contains(&"L5.safetensors".to_string()));
  109. assert!(exclude.contains(&"L8.safetensors".to_string()));
  110. }
  111. #[test]
  112. fn test_config_deserialize() {
  113. let c: Config = toml::from_str("ssh_user = \"a\"\nssh_key_path = \"/p\"\n[[clusters]]\nname = \"p\"\nmaster_ib_ip = \"1\"\nnodes = [{name=\"n1\",ssh_ip=\"10.0.0.10\",ib_ip=\"1\",rank=0},{name=\"n2\",ssh_ip=\"10.0.0.11\",ib_ip=\"2\",rank=1}]\n[nccl.\"p\"]\nib_hca = \"m\"\nsocket_ifname = \"e\"\nib_gid_index = 3\nib_disable = 0\n").unwrap();
  114. assert_eq!(c.clusters.len(), 1);
  115. assert_eq!(c.clusters[0].nodes.len(), 2);
  116. }
  117. #[test]
  118. fn test_config_deserialize_with_host_key() {
  119. let c: Config = toml::from_str("ssh_user = \"a\"\nssh_key_path = \"/k\"\n[[clusters]]\nname = \"s\"\nmaster_ib_ip = \"1\"\nnodes = [\n { name = \"n\", ssh_ip = \"2\", ib_ip = \"1\", rank = 0, host_key = \"SHA256:abc\" },\n]\n").unwrap();
  120. assert_eq!(c.clusters[0].nodes[0].host_key, Some("SHA256:abc".into()));
  121. }
  122. #[test]
  123. fn test_select_cluster_by_name() {
  124. let mut cfg = tcfg();
  125. cfg.nccl.insert("test-cluster".into(), tnccl());
  126. let (c, n) = select_cluster(&cfg, "test-cluster", None, &HashSet::new()).unwrap();
  127. assert_eq!(c.name, "test-cluster");
  128. assert_eq!(n.unwrap().ib_hca, "rocep1s0f0");
  129. }
  130. #[test]
  131. fn test_select_cluster_auto() {
  132. let mut cfg = tcfg();
  133. cfg.nccl.insert("test-cluster".into(), tnccl());
  134. let (c, _n) = select_cluster(&cfg, "auto", None, &HashSet::new()).unwrap();
  135. assert_eq!(c.name, "test-cluster");
  136. }
  137. #[test]
  138. fn test_select_cluster_auto_no_nccl_allows_single_node() {
  139. // With required_nodes = None, NCCL config is not required — the cluster
  140. // is selected even without NCCL config (the node count is unknown at
  141. // selection time, so we can't require it).
  142. let cfg = tcfg();
  143. let (c, n) = select_cluster(&cfg, "auto", None, &HashSet::new()).unwrap();
  144. assert_eq!(c.name, "test-cluster");
  145. assert!(n.is_none());
  146. }
  147. #[test]
  148. fn test_select_cluster_multi_node_requires_nccl() {
  149. // Multi-node (required_nodes > 1) without NCCL config → error.
  150. assert!(select_cluster(&tcfg(), "auto", Some(2), &HashSet::new()).is_err());
  151. }
  152. #[test]
  153. fn test_select_cluster_not_found() {
  154. let mut cfg = tcfg();
  155. cfg.clusters = vec![];
  156. assert!(select_cluster(&cfg, "x", None, &HashSet::new()).is_err());
  157. }
  158. #[test]
  159. fn test_select_cluster_by_name_found() {
  160. let mut nccl = HashMap::new();
  161. nccl.insert("test-cluster".into(), tnccl());
  162. let mut cfg = tcfg();
  163. cfg.nccl = nccl;
  164. let c = select_cluster_by_name(&cfg, "test-cluster").unwrap();
  165. assert_eq!(c.name, "test-cluster");
  166. }
  167. #[test]
  168. fn test_select_cluster_by_name_not_found() {
  169. assert!(select_cluster_by_name(&tcfg(), "x").is_err());
  170. }
  171. #[test]
  172. fn test_select_cluster_by_prefix() {
  173. let mut cfg = tcfg();
  174. cfg.nccl.insert("test-cluster".into(), tnccl());
  175. let c = select_cluster_by_name(&cfg, "test-c").unwrap();
  176. assert_eq!(c.name, "test-cluster");
  177. assert!(select_cluster_by_name(&cfg, "no-such").is_err());
  178. }
  179. #[test]
  180. fn test_default_engine_is_vllm() {
  181. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
  182. assert_eq!(r.engine, "vllm");
  183. }
  184. #[test]
  185. fn test_default_distributed_backend_is_none() {
  186. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
  187. assert_eq!(r.distributed_backend, None);
  188. }
  189. #[test]
  190. fn test_deploy_defaults_to_docker() {
  191. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
  192. assert_eq!(r.deploy, DeployTarget::Docker);
  193. }
  194. #[test]
  195. fn test_deploy_explicit_tmux() {
  196. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\ndeploy = \"tmux\"\n").unwrap();
  197. assert_eq!(r.deploy, DeployTarget::Tmux);
  198. }
  199. #[test]
  200. fn test_deploy_explicit_docker() {
  201. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\ndeploy = \"docker\"\n").unwrap();
  202. assert_eq!(r.deploy, DeployTarget::Docker);
  203. }
  204. #[test]
  205. fn test_venv_field() {
  206. let r: Recipe = toml::from_str(
  207. "name = \"x\"\nmodel = \"m\"\ndeploy = \"tmux\"\nvenv = \"~/venvs/vllm025\"\n",
  208. )
  209. .unwrap();
  210. assert_eq!(r.deploy, DeployTarget::Tmux);
  211. assert_eq!(r.venv.as_deref(), Some("~/venvs/vllm025"));
  212. }
  213. #[test]
  214. fn test_venv_defaults_to_none() {
  215. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
  216. assert_eq!(r.venv, None);
  217. }
  218. #[test]
  219. fn test_venv_setup_list_deserializes() {
  220. let r: Recipe = toml::from_str(
  221. r#"name = "x"
  222. model = "m"
  223. deploy = "tmux"
  224. venv = "~/venvs/vllm025"
  225. venv_setup = [
  226. "uv venv ~/venvs/vllm025 -p 3.12",
  227. "uv pip install vllm==0.25.1 --torch-backend=cu130",
  228. ]
  229. "#,
  230. )
  231. .unwrap();
  232. assert_eq!(r.venv.as_deref(), Some("~/venvs/vllm025"));
  233. let setup = r.venv_setup.to_vec();
  234. assert_eq!(setup.len(), 2);
  235. assert_eq!(setup[0], "uv venv ~/venvs/vllm025 -p 3.12");
  236. assert_eq!(
  237. setup[1],
  238. "uv pip install vllm==0.25.1 --torch-backend=cu130"
  239. );
  240. }
  241. #[test]
  242. fn test_venv_setup_inline_deserializes() {
  243. let r: Recipe = toml::from_str(
  244. r#"name = "x"
  245. model = "m"
  246. deploy = "tmux"
  247. venv = "~/venvs/vllm025"
  248. venv_setup = """
  249. uv venv ~/venvs/vllm025 -p 3.12
  250. uv pip install vllm==0.25.1 --torch-backend=cu130
  251. """
  252. "#,
  253. )
  254. .unwrap();
  255. let setup = r.venv_setup.to_vec();
  256. assert_eq!(setup.len(), 2);
  257. assert_eq!(setup[0], "uv venv ~/venvs/vllm025 -p 3.12");
  258. assert_eq!(
  259. setup[1],
  260. "uv pip install vllm==0.25.1 --torch-backend=cu130"
  261. );
  262. }
  263. #[test]
  264. fn test_venv_setup_inline_skips_comments_and_blanks() {
  265. let r: Recipe = toml::from_str(
  266. r#"name = "x"
  267. model = "m"
  268. deploy = "tmux"
  269. venv = "~/venvs/vllm025"
  270. venv_setup = """
  271. # create the venv
  272. uv venv ~/venvs/vllm025 -p 3.12
  273. # install deps
  274. uv pip install vllm==0.25.1
  275. """
  276. "#,
  277. )
  278. .unwrap();
  279. let setup = r.venv_setup.to_vec();
  280. assert_eq!(setup.len(), 2);
  281. assert_eq!(setup[0], "uv venv ~/venvs/vllm025 -p 3.12");
  282. assert_eq!(setup[1], "uv pip install vllm==0.25.1");
  283. }
  284. #[test]
  285. fn test_venv_setup_defaults_to_empty() {
  286. let r: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"\n").unwrap();
  287. assert!(r.venv_setup.to_vec().is_empty());
  288. }
  289. #[test]
  290. fn default_config_dir_appends_sm121() {
  291. let Some(d) = default_config_dir() else {
  292. return;
  293. };
  294. assert!(d.ends_with("sm121") && d.parent().is_some());
  295. }
  296. #[tokio::test]
  297. async fn test_merge_cluster_sources_adds_new_keeps_main() {
  298. let dir = std::env::temp_dir().join(format!("sm121-csrc-{}", std::process::id()));
  299. std::fs::create_dir_all(&dir).unwrap();
  300. let src_path = dir.join("clusters-extra.toml");
  301. // "extra" is new; "test-cluster" collides with tcfg's cluster (main wins).
  302. std::fs::write(
  303. &src_path,
  304. r#"
  305. ssh_user = "srcuser"
  306. ssh_key_path = "/src/key"
  307. [[clusters]]
  308. name = "extra"
  309. nodes = [{ name = "e0", ssh_ip = "10.9.0.1", rank = 0 }]
  310. [[clusters]]
  311. name = "test-cluster"
  312. nodes = [{ name = "intruder", ssh_ip = "10.66.0.1", rank = 0 }]
  313. [nccl."extra"]
  314. ib_hca = "mlx5_0"
  315. socket_ifname = "eth0"
  316. ib_disable = 0
  317. [before_serve."extra"]
  318. commands = ["echo hi"]
  319. [home_assistant]
  320. url = "http://ha.lan:8123"
  321. token = "sufficiently-long-test-token"
  322. shutdown_delay_seconds = 30
  323. "#,
  324. )
  325. .unwrap();
  326. let mut cfg = tcfg();
  327. cfg.cluster_sources = vec![src_path.to_string_lossy().into_owned()];
  328. cfg.ssh_user = String::new();
  329. cfg.ssh_key_path = std::path::PathBuf::new();
  330. merge_cluster_sources(&mut cfg).await.unwrap();
  331. assert_eq!(cfg.ssh_user, "srcuser", "empty ssh_user filled from source");
  332. assert_eq!(cfg.ssh_key_path, std::path::PathBuf::from("/src/key"));
  333. assert_eq!(
  334. cfg.clusters.len(),
  335. 2,
  336. "extra added, conflicting name not duplicated"
  337. );
  338. let main = cfg
  339. .clusters
  340. .iter()
  341. .find(|c| c.name == "test-cluster")
  342. .unwrap();
  343. assert_eq!(
  344. main.nodes[0].name, "node0",
  345. "main-config cluster wins on conflict"
  346. );
  347. assert!(cfg.clusters.iter().any(|c| c.name == "extra"));
  348. assert!(cfg.nccl.contains_key("extra"));
  349. assert!(cfg.before_serve.contains_key("extra"));
  350. // HA config merges from the source when main has none (power on from
  351. // other cwds depends on this).
  352. assert!(
  353. cfg.home_assistant.is_some(),
  354. "home_assistant filled from source"
  355. );
  356. assert_eq!(
  357. cfg.home_assistant.as_ref().unwrap().url,
  358. "http://ha.lan:8123"
  359. );
  360. // A main-config ssh_user is never overridden by a source; same rule
  361. // for home_assistant.
  362. let mut cfg2 = tcfg();
  363. cfg2.cluster_sources = vec![src_path.to_string_lossy().into_owned()];
  364. cfg2.home_assistant = Some(crate::config::HomeAssistantConfig {
  365. url: "http://main-ha:8123".into(),
  366. token: "main".into(),
  367. shutdown_delay_seconds: 5,
  368. });
  369. merge_cluster_sources(&mut cfg2).await.unwrap();
  370. assert_eq!(cfg2.ssh_user, "u", "main ssh_user wins over source");
  371. assert_eq!(
  372. cfg2.home_assistant.as_ref().unwrap().url,
  373. "http://main-ha:8123",
  374. "main home_assistant wins over source"
  375. );
  376. }
  377. #[tokio::test]
  378. async fn test_load_recipe_from_recipe_sources() {
  379. let dir = std::env::temp_dir().join(format!("sm121-rsrc-{}", std::process::id()));
  380. let rdir = dir.join("remote-recipes").join("remote-recipe");
  381. std::fs::create_dir_all(&rdir).unwrap();
  382. std::fs::write(rdir.join("recipe.toml"), "name = \"remote\"\n").unwrap();
  383. let recipe = load_recipe(
  384. "remote-recipe",
  385. &[dir.join("remote-recipes").to_string_lossy().into_owned()],
  386. )
  387. .await
  388. .unwrap();
  389. assert_eq!(recipe.name, "remote");
  390. }