config.rs 12 KB

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