config.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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 staging;
  52. pub use staging::{Staging, StagingFormat, StagingKind, StagingVerify};
  53. mod types;
  54. pub use types::*;
  55. impl Node {
  56. /// Host IP for engine exports; falls back to `ssh_ip` when `ib_ip` unset
  57. /// (single-node runs need no IB addressing).
  58. pub fn effective_ib_ip(&self) -> &str {
  59. self.ib_ip.as_deref().unwrap_or(&self.ssh_ip)
  60. }
  61. }
  62. impl Cluster {
  63. /// Effective master IP: explicit `master_ib_ip`, else the rank-0 node's
  64. /// effective (ib→ssh fallback) IP, else the first node's ssh IP, else empty.
  65. pub fn effective_master_ip(&self) -> String {
  66. if let Some(ip) = &self.master_ib_ip {
  67. return ip.clone();
  68. }
  69. self.nodes
  70. .iter()
  71. .find(|n| n.rank == 0)
  72. .or_else(|| self.nodes.first())
  73. .map(|n| n.effective_ib_ip().to_string())
  74. .unwrap_or_default()
  75. }
  76. }
  77. pub fn resolve_cluster_name<'a>(clusters: &'a [Cluster], name: &str) -> Result<&'a Cluster> {
  78. if let Some(c) = clusters.iter().find(|c| c.name == name) {
  79. return Ok(c);
  80. }
  81. let matches: Vec<&Cluster> = clusters
  82. .iter()
  83. .filter(|c| c.name.starts_with(name))
  84. .collect();
  85. match matches.len() {
  86. 0 => anyhow::bail!(fluent_i18n::t!("cluster-not-found", { "name" => name.to_string() })),
  87. 1 => Ok(matches[0]),
  88. n => anyhow::bail!(
  89. "Pref '{}' matches {} clusters: {}",
  90. name,
  91. n,
  92. matches
  93. .iter()
  94. .map(|c| c.name.as_str())
  95. .collect::<Vec<_>>()
  96. .join(", ")
  97. ),
  98. }
  99. }
  100. /// A `cluster_sources` file: extra `[[clusters]]` entries, optionally with
  101. /// per-cluster `[nccl.<name>]` / `[before_serve.<name>]` tables. Merged into
  102. /// the main [`Config`] by [`merge_cluster_sources`].
  103. #[derive(Deserialize, Default)]
  104. pub struct ClusterSource {
  105. /// SSH credentials for this source's clusters; merged into the main
  106. /// config only when it leaves them empty (first source wins).
  107. #[serde(default)]
  108. pub ssh_user: Option<String>,
  109. #[serde(default)]
  110. pub ssh_key_path: Option<std::path::PathBuf>,
  111. #[serde(default)]
  112. pub clusters: Vec<Cluster>,
  113. #[serde(default)]
  114. pub nccl: HashMap<String, NcclConfig>,
  115. #[serde(default)]
  116. pub before_serve: HashMap<String, BeforeServeTable>,
  117. /// HA endpoint for `power on`/`power off` from other cwds; merged only
  118. /// when the main config has none.
  119. #[serde(default)]
  120. pub home_assistant: Option<HomeAssistantConfig>,
  121. /// RouterOS devices for `[mikrotik]`-named `extra_entities`; merged per
  122. /// device by name (main-config entries win). Without this the name falls
  123. /// through to a raw HA lookup and 404s on every probe tick.
  124. #[serde(default)]
  125. pub mikrotik: Option<MikrotikConfig>,
  126. }
  127. /// Merge each `Config.cluster_sources` file into `config`: extra clusters
  128. /// (main-config entries win on name conflict; among sources, first wins),
  129. /// plus per-cluster `nccl` / `before_serve` tables.
  130. pub async fn merge_cluster_sources(config: &mut Config) -> Result<()> {
  131. for raw in &config.cluster_sources {
  132. let path = local_path(raw);
  133. let content = tokio::fs::read_to_string(&path)
  134. .await
  135. .with_context(|| format!("cluster source '{}'", path.display()))?;
  136. let src: ClusterSource = toml::from_str(&content)
  137. .with_context(|| format!("invalid cluster source '{}'", path.display()))?;
  138. if config.ssh_user.is_empty() {
  139. config.ssh_user = src.ssh_user.clone().unwrap_or_default();
  140. }
  141. if config.ssh_key_path.as_os_str().is_empty() {
  142. config.ssh_key_path = src.ssh_key_path.clone().unwrap_or_default();
  143. }
  144. if config.home_assistant.is_none() {
  145. config.home_assistant = src.home_assistant.clone();
  146. }
  147. match (&mut config.mikrotik, src.mikrotik) {
  148. (None, Some(m)) => config.mikrotik = Some(m),
  149. (Some(main_m), Some(src_m)) => {
  150. for d in src_m.devices {
  151. if !main_m.devices.iter().any(|x| x.name == d.name) {
  152. main_m.devices.push(d);
  153. }
  154. }
  155. }
  156. _ => {}
  157. }
  158. for c in src.clusters {
  159. if !config.clusters.iter().any(|x| x.name == c.name) {
  160. config.clusters.push(c);
  161. }
  162. }
  163. for (name, v) in src.nccl {
  164. config.nccl.entry(name).or_insert(v);
  165. }
  166. for (name, v) in src.before_serve {
  167. config.before_serve.entry(name).or_insert(v);
  168. }
  169. }
  170. Ok(())
  171. }
  172. /// Expand `~/...` against the local user's home (unlike `expand_tilde`,
  173. /// which targets the ssh user's home *on the node*). Absolute paths pass
  174. /// through.
  175. pub fn local_path(s: &str) -> std::path::PathBuf {
  176. if let (Some(rest), Some(home)) = (s.strip_prefix("~/"), dirs::home_dir()) {
  177. return home.join(rest);
  178. }
  179. std::path::PathBuf::from(s)
  180. }
  181. /// Whether NCCL configuration is required for cluster selection.
  182. ///
  183. /// NCCL config is only needed for multi-node distributed runs — for
  184. /// single-node recipes (`required_nodes == Some(1)`), no NCCL env vars
  185. /// are emitted, so the config is not required.
  186. fn nccl_required(required_nodes: Option<u32>) -> bool {
  187. required_nodes.is_some_and(|n| n > 1)
  188. }
  189. pub fn select_cluster<'c>(
  190. config: &'c Config,
  191. cluster_name: &str,
  192. required_nodes: Option<u32>,
  193. occupied: &HashSet<String>,
  194. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  195. if cluster_name != "auto" {
  196. let cluster = resolve_cluster_name(&config.clusters, cluster_name)?;
  197. let nccl = config.nccl.get(&cluster.name);
  198. if nccl_required(required_nodes) && nccl.is_none() {
  199. anyhow::bail!("{}", fluent_i18n::t!("nccl-config-missing"));
  200. }
  201. return Ok((cluster.clone(), nccl));
  202. }
  203. let free = |c: &Cluster| -> u32 {
  204. c.nodes
  205. .iter()
  206. .filter(|n| !occupied.contains(&n.name))
  207. .count() as u32
  208. };
  209. // For single-node recipes, NCCL config is not required — allow clusters
  210. // without it. For multi-node, require it.
  211. let candidates: Vec<&Cluster> = if nccl_required(required_nodes) {
  212. config
  213. .clusters
  214. .iter()
  215. .filter_map(|c| {
  216. let nccl = config.nccl.get(&c.name)?;
  217. Some((c, nccl))
  218. })
  219. .filter(|(c, _)| required_nodes.is_none_or(|r| free(c) >= r))
  220. .map(|(c, _)| c)
  221. .collect()
  222. } else {
  223. config
  224. .clusters
  225. .iter()
  226. .filter(|c| required_nodes.is_none_or(|r| free(c) >= r))
  227. .collect()
  228. };
  229. let mut candidates = candidates;
  230. candidates.sort_by_key(|c| c.nodes.len());
  231. if let Some(cluster) = candidates.into_iter().next() {
  232. let avail = free(cluster);
  233. let nccl = config.nccl.get(&cluster.name);
  234. info!(
  235. "{}",
  236. fluent_i18n::t!("cluster-selected", { "name" => cluster.name.to_string(), "nodes" => avail })
  237. );
  238. return Ok((cluster.clone(), nccl));
  239. }
  240. anyhow::bail!(fluent_i18n::t!("cluster-auto-fail"));
  241. }
  242. pub fn select_cluster_by_name(config: &Config, cluster_name: &str) -> Result<Cluster> {
  243. Ok(resolve_cluster_name(&config.clusters, cluster_name)?.clone())
  244. }
  245. /// Resolve a cluster for a **build**.
  246. ///
  247. /// A build runs the Docker image build on a single node, so NCCL config is
  248. /// never required here, even when the recipe's `required_nodes > 1` (the
  249. /// resulting image is later pushed/synced to the serving cluster). For
  250. /// `cluster_name != "auto"` the named cluster is used as-is; for `"auto"` the
  251. /// smallest cluster containing `build_node` is selected.
  252. pub fn resolve_build_cluster(
  253. config: &Config,
  254. cluster_name: &str,
  255. build_node: &str,
  256. ) -> Result<Cluster> {
  257. if cluster_name != "auto" {
  258. return select_cluster_by_name(config, cluster_name);
  259. }
  260. config
  261. .clusters
  262. .iter()
  263. .filter(|c| c.nodes.iter().any(|n| n.name == build_node))
  264. .min_by_key(|c| c.nodes.len())
  265. .cloned()
  266. .ok_or_else(|| anyhow::anyhow!("no cluster contains build node '{}'", build_node))
  267. }
  268. pub fn resolve_cluster<'c>(
  269. config: &'c Config,
  270. recipe: &Recipe,
  271. cluster_name: &str,
  272. occupied: &HashSet<String>,
  273. ) -> Result<(Cluster, Option<&'c NcclConfig>)> {
  274. let choice = if cluster_name != "auto" {
  275. cluster_name.to_string()
  276. } else if let Some(ref rc) = recipe.cluster {
  277. rc.clone()
  278. } else {
  279. "auto".to_string()
  280. };
  281. select_cluster(config, &choice, recipe.required_nodes, occupied)
  282. }
  283. pub async fn resolve_mods_dir(name: &str) -> Option<std::path::PathBuf> {
  284. let cwd = std::path::PathBuf::from(format!("recipes/{name}/mods"));
  285. if cwd.is_dir() {
  286. return Some(cwd);
  287. }
  288. let cfg = default_config_dir()?
  289. .join("recipes")
  290. .join(name)
  291. .join("mods");
  292. cfg.is_dir().then_some(cfg)
  293. }
  294. /// All recipe dirs in search order: `./recipes`, `.` (published-bundle
  295. /// clones are flat), each `recipe_sources` entry (`~` expanded), then the
  296. /// global config dir's `recipes/`.
  297. pub fn recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
  298. let mut v = vec![
  299. std::path::PathBuf::from("recipes"),
  300. std::path::PathBuf::from("."),
  301. ];
  302. for s in &config.recipe_sources {
  303. v.push(local_path(s));
  304. }
  305. if let Some(dir) = default_config_dir() {
  306. v.push(dir.join("recipes"));
  307. }
  308. v
  309. }
  310. /// Dirs worth a RECURSIVE inotify watch: only real recipe workspaces.
  311. ///
  312. /// `recipe_dirs` always includes the cwd itself (`.`) so flat-layout
  313. /// recipes (`<cwd>/<name>/recipe.toml`) are found — but watching `.`
  314. /// recursively is dangerous: in the HA add-on container the cwd is `/`, and
  315. /// notify's walk followed `/sys` symlink loops (subsystem → device →
  316. /// subsystem → …), re-adding watches forever — ~4.6 GB/h of allocation
  317. /// churn that the allocator ratcheted into the Pi's daily OOM
  318. /// (2026-10-13/14, dmesg `oom-kill … task=sm121`).
  319. ///
  320. /// Rules: watch `<dir>/recipes` when it exists (covers `reports/` and every
  321. /// `recipe.toml` beneath it); else watch a dir that directly holds
  322. /// `<name>/recipe.toml` recipes; never a filesystem root.
  323. pub fn watchable_recipe_dirs(config: &Config) -> Vec<std::path::PathBuf> {
  324. let mut out: Vec<std::path::PathBuf> = Vec::new();
  325. for d in recipe_dirs(config) {
  326. if d.as_os_str() == "/" || d.parent().is_none() {
  327. continue;
  328. }
  329. if d.join("recipes").is_dir() {
  330. out.push(d.join("recipes"));
  331. } else if d.is_dir()
  332. && std::fs::read_dir(&d)
  333. .ok()
  334. .is_some_and(|rd| rd.flatten().any(|e| e.path().join("recipe.toml").is_file()))
  335. {
  336. out.push(d);
  337. }
  338. }
  339. out.sort();
  340. out.dedup();
  341. out
  342. }
  343. /// Locate the recipe file (same search order as [`load_recipe`]) so reports
  344. /// land next to it.
  345. pub(crate) fn recipe_file(recipe_name: &str, sources: &[String]) -> Option<PathBuf> {
  346. let mut paths: Vec<PathBuf> = vec![
  347. PathBuf::from(format!("recipes/{}/recipe.toml", recipe_name)),
  348. PathBuf::from(format!("{}/recipe.toml", recipe_name)),
  349. ];
  350. for src in sources {
  351. paths.push(local_path(src).join(recipe_name).join("recipe.toml"));
  352. }
  353. if let Some(dir) = default_config_dir() {
  354. paths.push(dir.join("recipes").join(recipe_name).join("recipe.toml"));
  355. }
  356. paths.into_iter().find(|p| p.is_file())
  357. }
  358. /// Load a recipe by name. Search order: `./recipes/<name>/recipe.toml` (cwd),
  359. /// then each `sources` entry (a dir containing `<name>/recipe.toml`, `~`
  360. /// expanded), then `<default_config_dir>/recipes/<name>/recipe.toml`.
  361. /// Locate the directory that holds `<name>/recipe.toml`, using the same
  362. /// search order as [`load_recipe`]. Recipe-relative artifacts (a `{ script =
  363. /// "scripts/prepare.sh" }` hook, `venv_setup_script`, patch files) live next
  364. /// to the recipe in the shared recipe repo — not in the launcher binary.
  365. pub async fn recipe_dir(recipe_name: &str, sources: &[String]) -> Result<std::path::PathBuf> {
  366. let mut dirs: Vec<std::path::PathBuf> = vec![std::path::PathBuf::from("recipes")];
  367. for src in sources {
  368. dirs.push(local_path(src));
  369. }
  370. if let Some(dir) = default_config_dir() {
  371. dirs.push(dir.join("recipes"));
  372. }
  373. for d in dirs {
  374. let p = d.join(recipe_name).join("recipe.toml");
  375. if tokio::fs::metadata(&p).await.is_ok() {
  376. return Ok(d.join(recipe_name));
  377. }
  378. }
  379. anyhow::bail!("recipe '{recipe_name}' not found in any recipe source")
  380. }
  381. pub async fn load_recipe(recipe_name: &str, sources: &[String]) -> Result<Recipe> {
  382. // Search order: `recipes/<name>` (launcher workspace), then `<name>`
  383. // (published bundle layout — a clone of the recipe registry is flat),
  384. // then each source, then the global config dir.
  385. let mut paths: Vec<std::path::PathBuf> = vec![
  386. std::path::PathBuf::from(format!("recipes/{}/recipe.toml", recipe_name)),
  387. std::path::PathBuf::from(format!("{}/recipe.toml", recipe_name)),
  388. ];
  389. for src in sources {
  390. paths.push(local_path(src).join(recipe_name).join("recipe.toml"));
  391. }
  392. if let Some(dir) = default_config_dir() {
  393. paths.push(dir.join("recipes").join(recipe_name).join("recipe.toml"));
  394. }
  395. for p in &paths {
  396. if let Ok(content) = tokio::fs::read_to_string(p).await {
  397. return Ok(toml::from_str(&content)?);
  398. }
  399. }
  400. anyhow::bail!(
  401. "recipe '{}' not found; searched: {}",
  402. recipe_name,
  403. paths
  404. .iter()
  405. .map(|p| p.to_string_lossy())
  406. .collect::<Vec<_>>()
  407. .join(", ")
  408. )
  409. }
  410. pub fn default_config_dir() -> Option<std::path::PathBuf> {
  411. dirs::config_dir().map(|p| p.join("sm121"))
  412. }
  413. #[cfg(test)]
  414. mod tests;
  415. #[cfg(test)]
  416. mod mikrotik_tests;