config.rs 15 KB

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