config.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. use anyhow::{Context, Result};
  2. use serde::Deserialize;
  3. use std::collections::HashMap;
  4. use tracing::info;
  5. #[derive(Clone, Deserialize)]
  6. pub struct Node {
  7. pub name: String,
  8. pub ssh_ip: String,
  9. pub ib_ip: String,
  10. pub rank: u32,
  11. #[serde(default)]
  12. pub host_key: Option<String>,
  13. }
  14. #[derive(Clone, Deserialize)]
  15. pub struct NcclConfig {
  16. pub ib_hca: String,
  17. pub socket_ifname: String,
  18. pub ib_gid_index: u32,
  19. pub ib_disable: u32,
  20. }
  21. #[derive(Clone, Deserialize)]
  22. pub struct Cluster {
  23. pub name: String,
  24. pub master_ib_ip: String,
  25. pub nodes: Vec<Node>,
  26. }
  27. #[derive(Clone, Deserialize)]
  28. pub struct Config {
  29. pub ssh_user: String,
  30. pub ssh_key_path: String,
  31. pub clusters: Vec<Cluster>,
  32. #[serde(default)]
  33. pub nccl: HashMap<String, NcclConfig>,
  34. }
  35. #[derive(Clone, Deserialize)]
  36. pub struct Recipe {
  37. #[serde(default = "default_engine")]
  38. pub engine: String,
  39. pub name: String,
  40. #[serde(default)]
  41. pub docker_image: String,
  42. #[serde(default)]
  43. pub model: String,
  44. #[serde(default)]
  45. pub port: u16,
  46. #[serde(default)]
  47. pub required_nodes: Option<u32>,
  48. #[serde(default)]
  49. pub pipeline_parallel_size: Option<u32>,
  50. #[serde(default)]
  51. pub args: Vec<String>,
  52. #[serde(default)]
  53. pub env: HashMap<String, String>,
  54. #[serde(default)]
  55. pub cluster: Option<String>,
  56. #[serde(default)]
  57. pub entrypoint: Vec<String>,
  58. #[serde(default = "default_distributed_backend")]
  59. pub distributed_backend: String,
  60. }
  61. fn default_engine() -> String {
  62. "vllm".to_string()
  63. }
  64. fn default_distributed_backend() -> String {
  65. "mp".to_string()
  66. }
  67. /// Selects a cluster and its NCCL config by name, or auto-picks the first
  68. /// cluster that has NCCL config.
  69. pub fn select_cluster<'c>(config: &'c Config, cluster_name: &str) -> Result<(Cluster, &'c NcclConfig)> {
  70. if cluster_name != "auto" {
  71. let cluster = config
  72. .clusters
  73. .iter()
  74. .find(|c| c.name == cluster_name)
  75. .context(format!("Кластер {} не найден", cluster_name))?;
  76. let nccl = config
  77. .nccl
  78. .get(&cluster.name)
  79. .context("NCCL конфиг не найден")?;
  80. return Ok((cluster.clone(), nccl));
  81. }
  82. for cluster in &config.clusters {
  83. if let Some(nccl) = config.nccl.get(&cluster.name) {
  84. info!("✅ Автоподбор кластера: {}", cluster.name);
  85. return Ok((cluster.clone(), nccl));
  86. }
  87. }
  88. anyhow::bail!("Не удалось подобрать подходящий кластер");
  89. }
  90. /// Looks up a cluster by name without requiring NCCL config.
  91. pub fn select_cluster_by_name(config: &Config, cluster_name: &str) -> Result<Cluster> {
  92. let cluster = config
  93. .clusters
  94. .iter()
  95. .find(|c| c.name == cluster_name)
  96. .context(format!("Кластер {} не найден", cluster_name))?;
  97. Ok(cluster.clone())
  98. }
  99. /// Resolves the cluster for a recipe, applying recipe-level cluster pinning.
  100. pub fn resolve_cluster<'c>(
  101. config: &'c Config,
  102. recipe: &Recipe,
  103. cluster_name: &str,
  104. ) -> Result<(Cluster, &'c NcclConfig)> {
  105. let choice = if cluster_name != "auto" {
  106. cluster_name.to_string()
  107. } else if let Some(ref rc) = recipe.cluster {
  108. rc.clone()
  109. } else {
  110. "auto".to_string()
  111. };
  112. select_cluster(config, &choice)
  113. }
  114. /// Loads a recipe from disk.
  115. pub async fn load_recipe(recipe_name: &str) -> Result<Recipe> {
  116. let path = format!("recipes/{}/recipe.toml", recipe_name);
  117. let content = tokio::fs::read_to_string(&path).await?;
  118. Ok(toml::from_str(&content)?)
  119. }
  120. /// Returns the cluster and recipe name for a recipe (for stop/logs commands
  121. /// that only receive a recipe name).
  122. pub async fn resolve_cluster_for_recipe(config: &Config, recipe_name: &str) -> Result<(Cluster, Recipe)> {
  123. let recipe = load_recipe(recipe_name).await?;
  124. let (cluster, _) = resolve_cluster(config, &recipe, "auto")?;
  125. Ok((cluster, recipe))
  126. }
  127. #[cfg(test)]
  128. mod tests {
  129. use super::*;
  130. fn test_cluster() -> Cluster {
  131. Cluster {
  132. name: "test-cluster".to_string(),
  133. master_ib_ip: "192.168.1.1".to_string(),
  134. nodes: vec![
  135. Node {
  136. name: "node0".to_string(),
  137. ssh_ip: "10.0.0.1".to_string(),
  138. ib_ip: "192.168.1.1".to_string(),
  139. rank: 0,
  140. host_key: None,
  141. },
  142. Node {
  143. name: "node1".to_string(),
  144. ssh_ip: "10.0.0.2".to_string(),
  145. ib_ip: "192.168.1.2".to_string(),
  146. rank: 1,
  147. host_key: None,
  148. },
  149. ],
  150. }
  151. }
  152. fn test_nccl() -> NcclConfig {
  153. NcclConfig {
  154. ib_hca: "rocep1s0f0".to_string(),
  155. socket_ifname: "enp1s0f0np0".to_string(),
  156. ib_gid_index: 3,
  157. ib_disable: 0,
  158. }
  159. }
  160. // ── Deserialization tests ──────────────────────────────────
  161. #[test]
  162. fn test_recipe_deserialize_minimal() {
  163. let toml_str = r#"
  164. name = "Minimal"
  165. model = "test/model"
  166. "#;
  167. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  168. assert_eq!(recipe.name, "Minimal");
  169. assert_eq!(recipe.model, "test/model");
  170. assert_eq!(recipe.engine, "vllm");
  171. assert_eq!(recipe.port, 0);
  172. assert!(recipe.args.is_empty());
  173. assert!(recipe.env.is_empty());
  174. assert_eq!(recipe.distributed_backend, "mp");
  175. }
  176. #[test]
  177. fn test_recipe_deserialize_full() {
  178. let toml_str = r#"
  179. engine = "sglang"
  180. name = "Full Recipe"
  181. docker_image = "org/image:tag"
  182. model = "org/model"
  183. port = 30000
  184. required_nodes = 4
  185. pipeline_parallel_size = 2
  186. distributed_backend = "ray"
  187. cluster = "my-cluster"
  188. args = ["--arg1", "val1", "--arg2"]
  189. [env]
  190. KEY = "value"
  191. OTHER = "123"
  192. "#;
  193. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  194. assert_eq!(recipe.engine, "sglang");
  195. assert_eq!(recipe.name, "Full Recipe");
  196. assert_eq!(recipe.docker_image, "org/image:tag");
  197. assert_eq!(recipe.model, "org/model");
  198. assert_eq!(recipe.port, 30000);
  199. assert_eq!(recipe.required_nodes, Some(4));
  200. assert_eq!(recipe.pipeline_parallel_size, Some(2));
  201. assert_eq!(recipe.distributed_backend, "ray");
  202. assert_eq!(recipe.cluster, Some("my-cluster".to_string()));
  203. assert_eq!(recipe.args, vec!["--arg1", "val1", "--arg2"]);
  204. assert_eq!(recipe.env.get("KEY").unwrap(), "value");
  205. assert_eq!(recipe.env.get("OTHER").unwrap(), "123");
  206. }
  207. #[test]
  208. fn test_recipe_deserialize_no_required_nodes() {
  209. let toml_str = r#"
  210. name = "Flexible"
  211. model = "test/model"
  212. "#;
  213. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  214. assert_eq!(recipe.required_nodes, None);
  215. assert_eq!(recipe.pipeline_parallel_size, None);
  216. }
  217. #[test]
  218. fn test_config_deserialize() {
  219. let toml_str = r#"
  220. ssh_user = "admin"
  221. ssh_key_path = "/home/admin/.ssh/id_rsa"
  222. [[clusters]]
  223. name = "prod"
  224. master_ib_ip = "10.0.0.1"
  225. nodes = [
  226. { name = "node1", ssh_ip = "10.0.0.10", ib_ip = "10.0.0.1", rank = 0 },
  227. { name = "node2", ssh_ip = "10.0.0.11", ib_ip = "10.0.0.2", rank = 1 },
  228. ]
  229. [nccl."prod"]
  230. ib_hca = "mlx5_0"
  231. socket_ifname = "eth0"
  232. ib_gid_index = 3
  233. ib_disable = 0
  234. "#;
  235. let config: Config = toml::from_str(toml_str).unwrap();
  236. assert_eq!(config.ssh_user, "admin");
  237. assert_eq!(config.clusters.len(), 1);
  238. assert_eq!(config.clusters[0].nodes.len(), 2);
  239. assert_eq!(config.clusters[0].nodes[0].host_key, None);
  240. assert!(config.nccl.contains_key("prod"));
  241. assert_eq!(config.nccl["prod"].ib_hca, "mlx5_0");
  242. }
  243. #[test]
  244. fn test_config_deserialize_with_host_key() {
  245. let toml_str = r#"
  246. ssh_user = "admin"
  247. ssh_key_path = "/path/to/key"
  248. [[clusters]]
  249. name = "secure"
  250. master_ib_ip = "1.1.1.1"
  251. nodes = [
  252. { name = "n1", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0, host_key = "SHA256:abc123" },
  253. ]
  254. "#;
  255. let config: Config = toml::from_str(toml_str).unwrap();
  256. assert_eq!(
  257. config.clusters[0].nodes[0].host_key,
  258. Some("SHA256:abc123".to_string())
  259. );
  260. }
  261. // ── select_cluster tests ───────────────────────────────────
  262. #[test]
  263. fn test_select_cluster_by_name() {
  264. let mut config = Config {
  265. ssh_user: "user".to_string(),
  266. ssh_key_path: "/path/to/key".to_string(),
  267. clusters: vec![test_cluster()],
  268. nccl: HashMap::new(),
  269. };
  270. config.nccl.insert("test-cluster".to_string(), test_nccl());
  271. let (cluster, nccl) = select_cluster(&config, "test-cluster").unwrap();
  272. assert_eq!(cluster.name, "test-cluster");
  273. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  274. }
  275. #[test]
  276. fn test_select_cluster_auto() {
  277. let mut config = Config {
  278. ssh_user: "user".to_string(),
  279. ssh_key_path: "/path/to/key".to_string(),
  280. clusters: vec![test_cluster()],
  281. nccl: HashMap::new(),
  282. };
  283. config.nccl.insert("test-cluster".to_string(), test_nccl());
  284. let (cluster, nccl) = select_cluster(&config, "auto").unwrap();
  285. assert_eq!(cluster.name, "test-cluster");
  286. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  287. }
  288. #[test]
  289. fn test_select_cluster_auto_no_match() {
  290. let config = Config {
  291. ssh_user: "user".to_string(),
  292. ssh_key_path: "/path/to/key".to_string(),
  293. clusters: vec![test_cluster()],
  294. nccl: HashMap::new(),
  295. };
  296. let result = select_cluster(&config, "auto");
  297. assert!(result.is_err());
  298. }
  299. #[test]
  300. fn test_select_cluster_not_found() {
  301. let config = Config {
  302. ssh_user: "user".to_string(),
  303. ssh_key_path: "/path/to/key".to_string(),
  304. clusters: vec![],
  305. nccl: HashMap::new(),
  306. };
  307. let result = select_cluster(&config, "nonexistent");
  308. assert!(result.is_err());
  309. }
  310. #[test]
  311. fn test_select_cluster_by_name_found() {
  312. let mut nccl_map = HashMap::new();
  313. nccl_map.insert("test-cluster".to_string(), test_nccl());
  314. let config = Config {
  315. ssh_user: "u".to_string(),
  316. ssh_key_path: "/k".to_string(),
  317. clusters: vec![test_cluster()],
  318. nccl: nccl_map,
  319. };
  320. let cluster = select_cluster_by_name(&config, "test-cluster").unwrap();
  321. assert_eq!(cluster.name, "test-cluster");
  322. assert_eq!(cluster.nodes.len(), 2);
  323. }
  324. #[test]
  325. fn test_select_cluster_by_name_not_found() {
  326. let config = Config {
  327. ssh_user: "u".to_string(),
  328. ssh_key_path: "/k".to_string(),
  329. clusters: vec![test_cluster()],
  330. nccl: HashMap::new(),
  331. };
  332. let result = select_cluster_by_name(&config, "no-such-cluster");
  333. assert!(result.is_err());
  334. }
  335. // ── Defaults ───────────────────────────────────────────────
  336. #[test]
  337. fn test_default_engine_is_vllm() {
  338. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  339. assert_eq!(recipe.engine, "vllm");
  340. }
  341. #[test]
  342. fn test_default_distributed_backend_is_mp() {
  343. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  344. assert_eq!(recipe.distributed_backend, "mp");
  345. }
  346. }