config.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. pub use crate::pre_serve::{BeforeServeTable, OnFailure, PreServeCommand};
  2. use anyhow::{Context, Result};
  3. use serde::Deserialize;
  4. use std::collections::HashMap;
  5. use tracing::info;
  6. #[derive(Clone, Deserialize)]
  7. pub struct Node {
  8. pub name: String,
  9. pub ssh_ip: String,
  10. pub ib_ip: String,
  11. pub rank: u32,
  12. #[serde(default)]
  13. pub host_key: Option<String>,
  14. #[serde(default)]
  15. pub entity: Option<String>,
  16. }
  17. #[derive(Clone, Deserialize)]
  18. pub struct NcclConfig {
  19. pub ib_hca: String,
  20. pub socket_ifname: String,
  21. pub ib_gid_index: u32,
  22. pub ib_disable: u32,
  23. }
  24. #[derive(Clone, Deserialize)]
  25. pub struct Cluster {
  26. pub name: String,
  27. pub master_ib_ip: String,
  28. pub nodes: Vec<Node>,
  29. /// Extra Home Assistant switches to toggle alongside the nodes during power on/off.
  30. #[serde(default)]
  31. pub extra_entities: Vec<String>,
  32. }
  33. /// Top-level config (clusters + per-cluster pre-serve commands).
  34. #[derive(Clone, Deserialize)]
  35. pub struct Config {
  36. pub ssh_user: String,
  37. pub ssh_key_path: std::path::PathBuf,
  38. pub clusters: Vec<Cluster>,
  39. #[serde(default)]
  40. pub nccl: HashMap<String, NcclConfig>,
  41. #[serde(default)]
  42. pub hf_cache_dir: Option<String>,
  43. /// TCP port for cluster init (vLLM/SGLang)
  44. #[serde(default = "default_init_port")]
  45. pub init_port: u16,
  46. /// Per-cluster pre-serve commands, command aliases, HA config
  47. #[serde(default)]
  48. pub before_serve: HashMap<String, BeforeServeTable>, // <-- move here
  49. #[serde(default)]
  50. pub exec_aliases: HashMap<String, String>,
  51. #[serde(default)]
  52. pub home_assistant: Option<HomeAssistantConfig>,
  53. }
  54. /// Home Assistant config for `power on`/`power off`.
  55. #[derive(Clone, Deserialize)]
  56. pub struct HomeAssistantConfig {
  57. pub url: String,
  58. pub token: String,
  59. #[serde(default = "default_shutdown_delay")]
  60. pub shutdown_delay_seconds: u64,
  61. }
  62. fn default_shutdown_delay() -> u64 {
  63. 30
  64. }
  65. fn default_init_port() -> u16 {
  66. 25000
  67. }
  68. fn default_port() -> u16 {
  69. 30000
  70. }
  71. impl Config {
  72. pub fn resolved_hf_cache_dir(&self) -> String {
  73. if let Some(ref dir) = self.hf_cache_dir {
  74. return dir.clone();
  75. }
  76. let home = if self.ssh_user == "root" {
  77. "/root".to_string()
  78. } else {
  79. format!("/home/{}", self.ssh_user)
  80. };
  81. format!("{}/.cache/huggingface", home)
  82. }
  83. }
  84. #[derive(Clone, Deserialize)]
  85. pub struct Recipe {
  86. #[serde(default = "default_engine")]
  87. pub engine: String,
  88. pub name: String,
  89. #[serde(default)]
  90. pub docker_image: String,
  91. #[serde(default)]
  92. pub model: String,
  93. #[serde(default = "default_port")]
  94. pub port: u16,
  95. #[serde(default)]
  96. pub required_nodes: Option<u32>,
  97. #[serde(default)]
  98. pub pipeline_parallel_size: Option<u32>,
  99. #[serde(default = "default_tensor_parallel_size")]
  100. pub tensor_parallel_size: u32,
  101. #[serde(default)]
  102. pub args: Vec<String>,
  103. #[serde(default)]
  104. pub env: HashMap<String, String>,
  105. #[serde(default)]
  106. pub cluster: Option<String>,
  107. #[serde(default)]
  108. pub entrypoint: Vec<String>,
  109. #[serde(default = "default_distributed_backend")]
  110. pub distributed_backend: String,
  111. /// Shared memory size for the container (e.g. "64gb"). Omitted when empty.
  112. #[serde(default = "default_shm_size")]
  113. pub shm_size: String,
  114. /// Recipe-level pre-serve commands (appended after cluster-level).
  115. #[serde(default)]
  116. pub before_serve: Vec<PreServeCommand>,
  117. }
  118. /// Resolves cluster by exact name, then unique prefix. Errors if none or ambiguous.
  119. fn resolve_cluster_name<'a>(clusters: &'a [Cluster], name: &str) -> Result<&'a Cluster> {
  120. if let Some(c) = clusters.iter().find(|c| c.name == name) {
  121. return Ok(c);
  122. }
  123. let matches: Vec<&Cluster> = clusters
  124. .iter()
  125. .filter(|c| c.name.starts_with(name))
  126. .collect();
  127. match matches.len() {
  128. 0 => anyhow::bail!("Кластер {} не найден", name),
  129. 1 => Ok(matches[0]),
  130. n => anyhow::bail!(
  131. "Префикс '{}' соответствует {} кластерам: {}",
  132. name,
  133. n,
  134. matches
  135. .iter()
  136. .map(|c| c.name.as_str())
  137. .collect::<Vec<_>>()
  138. .join(", ")
  139. ),
  140. }
  141. }
  142. fn default_engine() -> String {
  143. "vllm".to_string()
  144. }
  145. fn default_distributed_backend() -> String {
  146. "mp".to_string()
  147. }
  148. fn default_shm_size() -> String {
  149. String::new()
  150. }
  151. fn default_tensor_parallel_size() -> u32 {
  152. 1
  153. }
  154. /// Selects a cluster + NCCL config by name or auto-picks the smallest
  155. /// cluster with NCCL config satisfying `required_nodes`.
  156. pub fn select_cluster<'c>(
  157. config: &'c Config,
  158. cluster_name: &str,
  159. required_nodes: Option<u32>,
  160. ) -> Result<(Cluster, &'c NcclConfig)> {
  161. if cluster_name != "auto" {
  162. let cluster = resolve_cluster_name(&config.clusters, cluster_name)?;
  163. let nccl = config
  164. .nccl
  165. .get(&cluster.name)
  166. .context("NCCL конфиг не найден")?;
  167. return Ok((cluster.clone(), nccl));
  168. }
  169. let mut candidates: Vec<(&Cluster, &NcclConfig)> = config
  170. .clusters
  171. .iter()
  172. .filter_map(|c| Some((c, config.nccl.get(&c.name)?)))
  173. .filter(|(c, _)| required_nodes.is_none_or(|r| (c.nodes.len() as u32) == r))
  174. .collect();
  175. candidates.sort_by_key(|(c, _)| c.nodes.len());
  176. if let Some((cluster, nccl)) = candidates.into_iter().next() {
  177. info!(
  178. "✅ Автоподбор кластера: {} ({} узлов)",
  179. cluster.name,
  180. cluster.nodes.len()
  181. );
  182. return Ok((cluster.clone(), nccl));
  183. }
  184. anyhow::bail!("Не удалось подобрать подходящий кластер");
  185. }
  186. /// Looks up a cluster by name (or unique prefix) without requiring NCCL config.
  187. pub fn select_cluster_by_name(config: &Config, cluster_name: &str) -> Result<Cluster> {
  188. Ok(resolve_cluster_name(&config.clusters, cluster_name)?.clone())
  189. }
  190. /// Resolves the cluster for a recipe, applying recipe-level cluster pinning.
  191. pub fn resolve_cluster<'c>(
  192. config: &'c Config,
  193. recipe: &Recipe,
  194. cluster_name: &str,
  195. ) -> Result<(Cluster, &'c NcclConfig)> {
  196. let choice = if cluster_name != "auto" {
  197. cluster_name.to_string()
  198. } else if let Some(ref rc) = recipe.cluster {
  199. rc.clone()
  200. } else {
  201. "auto".to_string()
  202. };
  203. select_cluster(config, &choice, recipe.required_nodes)
  204. }
  205. /// Loads a recipe (cwd first, then `<default_config_dir>/recipes/<name>/recipe.toml`,
  206. /// falling back to cwd for the error).
  207. pub async fn load_recipe(recipe_name: &str) -> Result<Recipe> {
  208. let cwd_path = format!("recipes/{}/recipe.toml", recipe_name);
  209. if let Ok(content) = tokio::fs::read_to_string(&cwd_path).await {
  210. return Ok(toml::from_str(&content)?);
  211. }
  212. if let Some(dir) = default_config_dir() {
  213. let in_default = dir.join("recipes").join(recipe_name).join("recipe.toml");
  214. if let Ok(content) = tokio::fs::read_to_string(&in_default).await {
  215. return Ok(toml::from_str(&content)?);
  216. }
  217. }
  218. // Neither found — let the cwd path produce a clean error.
  219. let content = tokio::fs::read_to_string(&cwd_path).await?;
  220. Ok(toml::from_str(&content)?)
  221. }
  222. /// OS-appropriate default config dir (e.g. `~/.config/sm121-launcher`),
  223. /// or `None` for sandboxes with no known config dir.
  224. pub fn default_config_dir() -> Option<std::path::PathBuf> {
  225. dirs::config_dir().map(|p| p.join("sm121-launcher"))
  226. }
  227. /// Resolves the cluster for a recipe (stop/logs flow).
  228. pub async fn resolve_cluster_for_recipe(
  229. config: &Config,
  230. recipe_name: &str,
  231. ) -> Result<(Cluster, Recipe)> {
  232. let recipe = load_recipe(recipe_name).await?;
  233. let (cluster, _) = resolve_cluster(config, &recipe, "auto")?;
  234. Ok((cluster, recipe))
  235. }
  236. #[cfg(test)]
  237. mod tests {
  238. use super::*;
  239. fn test_cluster() -> Cluster {
  240. Cluster {
  241. name: "test-cluster".to_string(),
  242. master_ib_ip: "192.168.1.1".to_string(),
  243. nodes: vec![
  244. Node {
  245. name: "node0".to_string(),
  246. ssh_ip: "10.0.0.1".to_string(),
  247. ib_ip: "192.168.1.1".to_string(),
  248. rank: 0,
  249. host_key: None,
  250. entity: None,
  251. },
  252. Node {
  253. name: "node1".to_string(),
  254. ssh_ip: "10.0.0.2".to_string(),
  255. ib_ip: "192.168.1.2".to_string(),
  256. rank: 1,
  257. host_key: None,
  258. entity: None,
  259. },
  260. ],
  261. extra_entities: vec![],
  262. }
  263. }
  264. fn test_config() -> Config {
  265. Config {
  266. ssh_user: "u".to_string(),
  267. ssh_key_path: "/k".into(),
  268. clusters: vec![test_cluster()],
  269. nccl: HashMap::new(),
  270. hf_cache_dir: None,
  271. init_port: 25000,
  272. before_serve: HashMap::new(),
  273. exec_aliases: HashMap::new(),
  274. home_assistant: None,
  275. }
  276. }
  277. fn test_nccl() -> NcclConfig {
  278. NcclConfig {
  279. ib_hca: "rocep1s0f0".to_string(),
  280. socket_ifname: "enp1s0f0np0".to_string(),
  281. ib_gid_index: 3,
  282. ib_disable: 0,
  283. }
  284. }
  285. // ── Deserialization tests ──────────────────────────────────
  286. #[test]
  287. fn test_recipe_deserialize_minimal() {
  288. let toml_str = r#"
  289. name = "Minimal"
  290. model = "test/model"
  291. "#;
  292. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  293. assert_eq!(recipe.name, "Minimal");
  294. assert_eq!(recipe.model, "test/model");
  295. assert_eq!(recipe.engine, "vllm");
  296. assert_eq!(recipe.port, 30000);
  297. assert!(recipe.args.is_empty());
  298. assert!(recipe.env.is_empty());
  299. assert_eq!(recipe.distributed_backend, "mp");
  300. assert_eq!(recipe.tensor_parallel_size, 1);
  301. }
  302. #[test]
  303. fn test_recipe_deserialize_full() {
  304. let toml_str = r#"
  305. engine = "sglang"
  306. name = "Full Recipe"
  307. docker_image = "org/image:tag"
  308. model = "org/model"
  309. port = 30000
  310. required_nodes = 4
  311. pipeline_parallel_size = 2
  312. distributed_backend = "ray"
  313. cluster = "my-cluster"
  314. args = ["--arg1", "val1", "--arg2"]
  315. [env]
  316. KEY = "value"
  317. OTHER = "123"
  318. "#;
  319. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  320. assert_eq!(recipe.engine, "sglang");
  321. assert_eq!(recipe.name, "Full Recipe");
  322. assert_eq!(recipe.docker_image, "org/image:tag");
  323. assert_eq!(recipe.model, "org/model");
  324. assert_eq!(recipe.port, 30000);
  325. assert_eq!(recipe.required_nodes, Some(4));
  326. assert_eq!(recipe.pipeline_parallel_size, Some(2));
  327. assert_eq!(recipe.tensor_parallel_size, 1);
  328. assert_eq!(recipe.distributed_backend, "ray");
  329. assert_eq!(recipe.cluster, Some("my-cluster".to_string()));
  330. assert_eq!(recipe.args, vec!["--arg1", "val1", "--arg2"]);
  331. assert_eq!(recipe.env.get("KEY").unwrap(), "value");
  332. assert_eq!(recipe.env.get("OTHER").unwrap(), "123");
  333. }
  334. #[test]
  335. fn test_recipe_deserialize_no_required_nodes() {
  336. let toml_str = r#"
  337. name = "Flexible"
  338. model = "test/model"
  339. "#;
  340. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  341. assert_eq!(recipe.required_nodes, None);
  342. assert_eq!(recipe.pipeline_parallel_size, None);
  343. }
  344. #[test]
  345. fn test_config_deserialize() {
  346. let toml_str = r#"
  347. ssh_user = "admin"
  348. ssh_key_path = "/home/admin/.ssh/id_rsa"
  349. [[clusters]]
  350. name = "prod"
  351. master_ib_ip = "10.0.0.1"
  352. nodes = [
  353. { name = "node1", ssh_ip = "10.0.0.10", ib_ip = "10.0.0.1", rank = 0 },
  354. { name = "node2", ssh_ip = "10.0.0.11", ib_ip = "10.0.0.2", rank = 1 },
  355. ]
  356. [nccl."prod"]
  357. ib_hca = "mlx5_0"
  358. socket_ifname = "eth0"
  359. ib_gid_index = 3
  360. ib_disable = 0
  361. "#;
  362. let config: Config = toml::from_str(toml_str).unwrap();
  363. assert_eq!(config.ssh_user, "admin");
  364. assert_eq!(config.clusters.len(), 1);
  365. assert_eq!(config.clusters[0].nodes.len(), 2);
  366. assert_eq!(config.clusters[0].nodes[0].host_key, None);
  367. assert!(config.nccl.contains_key("prod"));
  368. assert_eq!(config.nccl["prod"].ib_hca, "mlx5_0");
  369. }
  370. #[test]
  371. fn test_config_deserialize_with_host_key() {
  372. let toml_str = r#"
  373. ssh_user = "admin"
  374. ssh_key_path = "/path/to/key"
  375. [[clusters]]
  376. name = "secure"
  377. master_ib_ip = "1.1.1.1"
  378. nodes = [
  379. { name = "n1", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0, host_key = "SHA256:abc123" },
  380. ]
  381. "#;
  382. let config: Config = toml::from_str(toml_str).unwrap();
  383. assert_eq!(
  384. config.clusters[0].nodes[0].host_key,
  385. Some("SHA256:abc123".to_string())
  386. );
  387. }
  388. #[test]
  389. fn test_select_cluster_by_name() {
  390. let mut config = Config {
  391. ssh_user: "user".to_string(),
  392. ssh_key_path: "/path/to/key".into(),
  393. clusters: vec![test_cluster()],
  394. nccl: HashMap::new(),
  395. hf_cache_dir: None,
  396. init_port: 25000,
  397. before_serve: HashMap::new(),
  398. exec_aliases: HashMap::new(),
  399. home_assistant: None,
  400. };
  401. config.nccl.insert("test-cluster".to_string(), test_nccl());
  402. let (cluster, nccl) = select_cluster(&config, "test-cluster", None).unwrap();
  403. assert_eq!(cluster.name, "test-cluster");
  404. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  405. }
  406. #[test]
  407. fn test_select_cluster_auto() {
  408. let mut config = Config {
  409. ssh_user: "user".to_string(),
  410. ssh_key_path: "/path/to/key".into(),
  411. clusters: vec![test_cluster()],
  412. nccl: HashMap::new(),
  413. hf_cache_dir: None,
  414. init_port: 25000,
  415. before_serve: HashMap::new(),
  416. exec_aliases: HashMap::new(),
  417. home_assistant: None,
  418. };
  419. config.nccl.insert("test-cluster".to_string(), test_nccl());
  420. let (cluster, nccl) = select_cluster(&config, "auto", None).unwrap();
  421. assert_eq!(cluster.name, "test-cluster");
  422. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  423. }
  424. #[test]
  425. fn test_select_cluster_auto_no_match() {
  426. let config = test_config();
  427. let result = select_cluster(&config, "auto", None);
  428. assert!(result.is_err());
  429. }
  430. #[test]
  431. fn test_select_cluster_not_found() {
  432. let mut config = test_config();
  433. config.clusters = vec![];
  434. let result = select_cluster(&config, "nonexistent", None);
  435. assert!(result.is_err());
  436. }
  437. #[test]
  438. fn test_select_cluster_by_name_found() {
  439. let mut nccl_map = HashMap::new();
  440. nccl_map.insert("test-cluster".to_string(), test_nccl());
  441. let mut config = test_config();
  442. config.nccl = nccl_map;
  443. let cluster = select_cluster_by_name(&config, "test-cluster").unwrap();
  444. assert_eq!(cluster.name, "test-cluster");
  445. assert_eq!(cluster.nodes.len(), 2);
  446. }
  447. #[test]
  448. fn test_select_cluster_by_name_not_found() {
  449. let config = test_config();
  450. let result = select_cluster_by_name(&config, "no-such-cluster");
  451. assert!(result.is_err());
  452. }
  453. #[test]
  454. fn test_select_cluster_by_prefix() {
  455. let mut config = test_config();
  456. config.nccl.insert("test-cluster".to_string(), test_nccl());
  457. // Unique prefix works
  458. let cluster = select_cluster_by_name(&config, "test-c").unwrap();
  459. assert_eq!(cluster.name, "test-cluster");
  460. // No match errors
  461. let result = select_cluster_by_name(&config, "no-such");
  462. assert!(result.is_err());
  463. }
  464. #[test]
  465. fn test_default_engine_is_vllm() {
  466. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  467. assert_eq!(recipe.engine, "vllm");
  468. }
  469. #[test]
  470. fn test_default_distributed_backend_is_mp() {
  471. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  472. assert_eq!(recipe.distributed_backend, "mp");
  473. }
  474. // ── Default config dir ─────────────────────────────────────
  475. #[test]
  476. fn default_config_dir_appends_sm121_launcher() {
  477. let Some(dir) = default_config_dir() else {
  478. // No config dir on this system; nothing to assert.
  479. return;
  480. };
  481. assert!(dir.ends_with("sm121-launcher"));
  482. assert!(dir.parent().is_some());
  483. }
  484. }