config.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. use anyhow::{Context, Result};
  2. use serde::Deserialize;
  3. use std::collections::{HashMap, HashSet};
  4. use tracing::info;
  5. /// A cluster-level HA entity (smart plug, switch, fan, …).
  6. ///
  7. /// Two TOML forms:
  8. /// ```toml
  9. /// # blocking (default) — "off" takes the whole cluster down
  10. /// extra_entities = ["switch.network"]
  11. /// # non-blocking — tracked & toggled by `power`, but "off" does NOT
  12. /// # make the cluster offline (e.g. a fan that may be off while serving)
  13. /// extra_entities = [{ entity = "switch.fan", blocks = false }]
  14. /// ```
  15. #[derive(Clone, Debug, Deserialize, PartialEq)]
  16. #[serde(untagged)]
  17. pub enum ExtraEntity {
  18. /// Bare string — always blocking.
  19. Simple(String),
  20. /// Table `{ entity = "...", blocks = <bool> }`; `blocks` defaults to true.
  21. Detailed {
  22. entity: String,
  23. #[serde(default = "default_true")]
  24. blocks: bool,
  25. },
  26. }
  27. fn default_true() -> bool {
  28. true
  29. }
  30. impl ExtraEntity {
  31. /// The HA entity id (`domain.name`).
  32. pub fn entity(&self) -> &str {
  33. match self {
  34. ExtraEntity::Simple(s) => s,
  35. ExtraEntity::Detailed { entity, .. } => entity,
  36. }
  37. }
  38. /// Whether an "off" state counts against the cluster's up/down.
  39. /// Bare strings are always blocking; `blocks = false` opts out.
  40. pub fn blocks(&self) -> bool {
  41. match self {
  42. ExtraEntity::Simple(_) => true,
  43. ExtraEntity::Detailed { blocks, .. } => *blocks,
  44. }
  45. }
  46. }
  47. mod types;
  48. pub use types::*;
  49. impl Node {
  50. /// Host IP for engine exports; falls back to `ssh_ip` when `ib_ip` unset
  51. /// (single-node runs need no IB addressing).
  52. pub fn effective_ib_ip(&self) -> &str {
  53. self.ib_ip.as_deref().unwrap_or(&self.ssh_ip)
  54. }
  55. }
  56. impl Cluster {
  57. /// Effective master IP: explicit `master_ib_ip`, else the rank-0 node's
  58. /// effective (ib→ssh fallback) IP, else the first node's ssh IP, else empty.
  59. pub fn effective_master_ip(&self) -> String {
  60. if let Some(ip) = &self.master_ib_ip {
  61. return ip.clone();
  62. }
  63. self.nodes
  64. .iter()
  65. .find(|n| n.rank == 0)
  66. .or_else(|| self.nodes.first())
  67. .map(|n| n.effective_ib_ip().to_string())
  68. .unwrap_or_default()
  69. }
  70. }
  71. pub fn resolve_cluster_name<'a>(clusters: &'a [Cluster], name: &str) -> Result<&'a Cluster> {
  72. if let Some(c) = clusters.iter().find(|c| c.name == name) {
  73. return Ok(c);
  74. }
  75. let matches: Vec<&Cluster> = clusters
  76. .iter()
  77. .filter(|c| c.name.starts_with(name))
  78. .collect();
  79. match matches.len() {
  80. 0 => anyhow::bail!(fluent_i18n::t!("cluster-not-found", { "name" => name.to_string() })),
  81. 1 => Ok(matches[0]),
  82. n => anyhow::bail!(
  83. "Pref '{}' matches {} clusters: {}",
  84. name,
  85. n,
  86. matches
  87. .iter()
  88. .map(|c| c.name.as_str())
  89. .collect::<Vec<_>>()
  90. .join(", ")
  91. ),
  92. }
  93. }
  94. /// A `cluster_sources` file: extra `[[clusters]]` entries, optionally with
  95. /// per-cluster `[nccl.<name>]` / `[before_serve.<name>]` tables. Merged into
  96. /// the main [`Config`] by [`merge_cluster_sources`].
  97. #[derive(Deserialize, Default)]
  98. pub struct ClusterSource {
  99. /// SSH credentials for this source's clusters; merged into the main
  100. /// config only when it leaves them empty (first source wins).
  101. #[serde(default)]
  102. pub ssh_user: Option<String>,
  103. #[serde(default)]
  104. pub ssh_key_path: Option<std::path::PathBuf>,
  105. #[serde(default)]
  106. pub clusters: Vec<Cluster>,
  107. #[serde(default)]
  108. pub nccl: HashMap<String, NcclConfig>,
  109. #[serde(default)]
  110. pub before_serve: HashMap<String, BeforeServeTable>,
  111. /// HA endpoint for `power on`/`power off` from other cwds; merged only
  112. /// when the main config has none.
  113. #[serde(default)]
  114. pub home_assistant: Option<HomeAssistantConfig>,
  115. }
  116. /// Merge each `Config.cluster_sources` file into `config`: extra clusters
  117. /// (main-config entries win on name conflict; among sources, first wins),
  118. /// plus per-cluster `nccl` / `before_serve` tables.
  119. pub async fn merge_cluster_sources(config: &mut Config) -> Result<()> {
  120. for raw in &config.cluster_sources {
  121. let path = local_path(raw);
  122. let content = tokio::fs::read_to_string(&path)
  123. .await
  124. .with_context(|| format!("cluster source '{}'", path.display()))?;
  125. let src: ClusterSource = toml::from_str(&content)
  126. .with_context(|| format!("invalid cluster source '{}'", path.display()))?;
  127. if config.ssh_user.is_empty() {
  128. config.ssh_user = src.ssh_user.clone().unwrap_or_default();
  129. }
  130. if config.ssh_key_path.as_os_str().is_empty() {
  131. config.ssh_key_path = src.ssh_key_path.clone().unwrap_or_default();
  132. }
  133. if config.home_assistant.is_none() {
  134. config.home_assistant = src.home_assistant.clone();
  135. }
  136. for c in src.clusters {
  137. if !config.clusters.iter().any(|x| x.name == c.name) {
  138. config.clusters.push(c);
  139. }
  140. }
  141. for (name, v) in src.nccl {
  142. config.nccl.entry(name).or_insert(v);
  143. }
  144. for (name, v) in src.before_serve {
  145. config.before_serve.entry(name).or_insert(v);
  146. }
  147. }
  148. Ok(())
  149. }
  150. /// Expand `~/...` against the local user's home (unlike `expand_tilde`,
  151. /// which targets the ssh user's home *on the node*). Absolute paths pass
  152. /// through.
  153. pub fn local_path(s: &str) -> std::path::PathBuf {
  154. if let (Some(rest), Some(home)) = (s.strip_prefix("~/"), dirs::home_dir()) {
  155. return home.join(rest);
  156. }
  157. std::path::PathBuf::from(s)
  158. }
  159. /// Whether NCCL configuration is required for cluster selection.
  160. ///
  161. /// NCCL config is only needed for multi-node distributed runs — for
  162. /// single-node recipes (`required_nodes == Some(1)`), no NCCL env vars
  163. /// are emitted, so the config is not required.
  164. fn nccl_required(required_nodes: Option<u32>) -> bool {
  165. required_nodes.is_some_and(|n| n > 1)
  166. }
  167. pub fn select_cluster<'c>(
  168. config: &'c Config,
  169. cluster_name: &str,
  170. required_nodes: Option<u32>,
  171. occupied: &HashSet<String>,
  172. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  173. if cluster_name != "auto" {
  174. let cluster = resolve_cluster_name(&config.clusters, cluster_name)?;
  175. let nccl = config.nccl.get(&cluster.name);
  176. if nccl_required(required_nodes) && nccl.is_none() {
  177. anyhow::bail!("{}", fluent_i18n::t!("nccl-config-missing"));
  178. }
  179. return Ok((cluster.clone(), nccl));
  180. }
  181. let free = |c: &Cluster| -> u32 {
  182. c.nodes
  183. .iter()
  184. .filter(|n| !occupied.contains(&n.name))
  185. .count() as u32
  186. };
  187. // For single-node recipes, NCCL config is not required — allow clusters
  188. // without it. For multi-node, require it.
  189. let candidates: Vec<&Cluster> = if nccl_required(required_nodes) {
  190. config
  191. .clusters
  192. .iter()
  193. .filter_map(|c| {
  194. let nccl = config.nccl.get(&c.name)?;
  195. Some((c, nccl))
  196. })
  197. .filter(|(c, _)| required_nodes.is_none_or(|r| free(c) >= r))
  198. .map(|(c, _)| c)
  199. .collect()
  200. } else {
  201. config
  202. .clusters
  203. .iter()
  204. .filter(|c| required_nodes.is_none_or(|r| free(c) >= r))
  205. .collect()
  206. };
  207. let mut candidates = candidates;
  208. candidates.sort_by_key(|c| c.nodes.len());
  209. if let Some(cluster) = candidates.into_iter().next() {
  210. let avail = free(cluster);
  211. let nccl = config.nccl.get(&cluster.name);
  212. info!(
  213. "{}",
  214. fluent_i18n::t!("cluster-selected", { "name" => cluster.name.to_string(), "nodes" => avail })
  215. );
  216. return Ok((cluster.clone(), nccl));
  217. }
  218. anyhow::bail!(fluent_i18n::t!("cluster-auto-fail"));
  219. }
  220. pub fn select_cluster_by_name(config: &Config, cluster_name: &str) -> Result<Cluster> {
  221. Ok(resolve_cluster_name(&config.clusters, cluster_name)?.clone())
  222. }
  223. /// Resolve a cluster for a **build**.
  224. ///
  225. /// A build runs the Docker image build on a single node, so NCCL config is
  226. /// never required here, even when the recipe's `required_nodes > 1` (the
  227. /// resulting image is later pushed/synced to the serving cluster). For
  228. /// `cluster_name != "auto"` the named cluster is used as-is; for `"auto"` the
  229. /// smallest cluster containing `build_node` is selected.
  230. pub fn resolve_build_cluster(
  231. config: &Config,
  232. cluster_name: &str,
  233. build_node: &str,
  234. ) -> Result<Cluster> {
  235. if cluster_name != "auto" {
  236. return select_cluster_by_name(config, cluster_name);
  237. }
  238. config
  239. .clusters
  240. .iter()
  241. .filter(|c| c.nodes.iter().any(|n| n.name == build_node))
  242. .min_by_key(|c| c.nodes.len())
  243. .cloned()
  244. .ok_or_else(|| anyhow::anyhow!("no cluster contains build node '{}'", build_node))
  245. }
  246. pub fn resolve_cluster<'c>(
  247. config: &'c Config,
  248. recipe: &Recipe,
  249. cluster_name: &str,
  250. occupied: &HashSet<String>,
  251. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  252. let choice = if cluster_name != "auto" {
  253. cluster_name.to_string()
  254. } else if let Some(ref rc) = recipe.cluster {
  255. rc.clone()
  256. } else {
  257. "auto".to_string()
  258. };
  259. select_cluster(config, &choice, recipe.required_nodes, occupied)
  260. }
  261. pub async fn resolve_mods_dir(name: &str) -> Option<std::path::PathBuf> {
  262. let cwd = std::path::PathBuf::from(format!("recipes/{name}/mods"));
  263. if cwd.is_dir() {
  264. return Some(cwd);
  265. }
  266. let cfg = default_config_dir()?
  267. .join("recipes")
  268. .join(name)
  269. .join("mods");
  270. cfg.is_dir().then_some(cfg)
  271. }
  272. /// All recipe dirs in search order: `./recipes`, `.` (published-bundle
  273. /// clones are flat), each `recipe_sources` entry (`~` expanded), then the
  274. /// global config dir's `recipes/`.
  275. pub fn recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
  276. let mut v = vec![
  277. std::path::PathBuf::from("recipes"),
  278. std::path::PathBuf::from("."),
  279. ];
  280. for s in &config.recipe_sources {
  281. v.push(local_path(s));
  282. }
  283. if let Some(dir) = default_config_dir() {
  284. v.push(dir.join("recipes"));
  285. }
  286. v
  287. }
  288. /// Load a recipe by name. Search order: `./recipes/<name>/recipe.toml` (cwd),
  289. /// then each `sources` entry (a dir containing `<name>/recipe.toml`, `~`
  290. /// expanded), then `<default_config_dir>/recipes/<name>/recipe.toml`.
  291. pub async fn load_recipe(recipe_name: &str, sources: &[String]) -> Result<Recipe> {
  292. // Search order: `recipes/<name>` (launcher workspace), then `<name>`
  293. // (published bundle layout — a clone of the recipe registry is flat),
  294. // then each source, then the global config dir.
  295. let mut paths: Vec<std::path::PathBuf> = vec![
  296. std::path::PathBuf::from(format!("recipes/{}/recipe.toml", recipe_name)),
  297. std::path::PathBuf::from(format!("{}/recipe.toml", recipe_name)),
  298. ];
  299. for src in sources {
  300. paths.push(local_path(src).join(recipe_name).join("recipe.toml"));
  301. }
  302. if let Some(dir) = default_config_dir() {
  303. paths.push(dir.join("recipes").join(recipe_name).join("recipe.toml"));
  304. }
  305. for p in &paths {
  306. if let Ok(content) = tokio::fs::read_to_string(p).await {
  307. return Ok(toml::from_str(&content)?);
  308. }
  309. }
  310. anyhow::bail!(
  311. "recipe '{}' not found; searched: {}",
  312. recipe_name,
  313. paths
  314. .iter()
  315. .map(|p| p.to_string_lossy())
  316. .collect::<Vec<_>>()
  317. .join(", ")
  318. )
  319. }
  320. pub fn default_config_dir() -> Option<std::path::PathBuf> {
  321. dirs::config_dir().map(|p| p.join("sm121"))
  322. }
  323. #[cfg(test)]
  324. mod tests;