config.rs 17 KB

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