power.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. //! `power on` / `power off` subcommands.
  2. //!
  3. //! Power-off: SSH `sudo shutdown now` to each node → wait
  4. //! `[home_assistant].shutdown_delay_seconds` (default 30s) → POST
  5. //! `turn_off` to Home Assistant for each per-node `entity` and each
  6. //! cluster-level `extra_entities`.
  7. //!
  8. //! Power-on: just POST `turn_on` to Home Assistant for each per-node
  9. //! `entity` and each cluster-level `extra_entities` (the smart plug
  10. //! handles waking the machine).
  11. use anyhow::Result;
  12. use std::time::Duration;
  13. use tracing::{info, warn};
  14. use crate::config::{Cluster, Config, Node};
  15. use crate::home_assistant::Client as HaClient;
  16. pub mod plug;
  17. pub mod toggle;
  18. pub use plug::{OnAction, decide_on, stale_nodes};
  19. use toggle::{call, extra_ids, off_extras, toggle_entities, toggle_one};
  20. mod mikro;
  21. /// Flow for `power off`: SSH shutdown + wait + HA `turn_off`.
  22. pub async fn power_off(
  23. config: &Config,
  24. cluster: &Cluster,
  25. node_names: &[String],
  26. dry_run: bool,
  27. ) -> Result<()> {
  28. let targets = select_targets(cluster, node_names, "power off")?;
  29. // Cluster-level extras (the fabric switch, a cooling fan) are SHARED by
  30. // every node in the cluster. Powering off a SUBSET (`--node`) must not
  31. // take the fabric down for the nodes that stay up — that wedges live
  32. // multi-node serves (NCCL over the switch dies). And even a whole-cluster
  33. // off must keep any extra that ANOTHER cluster with an open run references
  34. // (the fabric switch is shared across clusters — powering `8nodes-switch`
  35. // down must not kill a serve on `dgx58-switch` over the same switch).
  36. let held = toggle::extras_held_by_other_clusters(config, cluster);
  37. let held_ids: std::collections::HashSet<String> = held.keys().cloned().collect();
  38. let extras = off_extras(cluster, node_names, &held_ids);
  39. if !node_names.is_empty() && !cluster.extra_entities.is_empty() {
  40. info!(
  41. "{}",
  42. fluent_i18n::t!("power-off-extras-skipped", {
  43. "nodes" => node_names.join(", "),
  44. "extras" => extra_ids(cluster).join(", ")
  45. })
  46. );
  47. }
  48. if node_names.is_empty() {
  49. for e in extra_ids(cluster) {
  50. if let Some(clusters) = held.get(&e) {
  51. info!(
  52. "{}",
  53. fluent_i18n::t!("power-off-extras-held", {
  54. "entity" => e.clone(),
  55. "clusters" => clusters.join(", ")
  56. })
  57. );
  58. }
  59. }
  60. }
  61. if dry_run {
  62. warn!("{}", fluent_i18n::t!("power-off-dryrun"));
  63. mikro::dry_off(config, &targets, &extras);
  64. return Ok(());
  65. }
  66. // 1) SSH shutdown every target in parallel.
  67. // NOTE: This is fire-and-forget — if a node is unreachable, its
  68. // SSH failure is logged but the flow continues. The HA `turn_off`
  69. // still fires for all targets regardless of SSH success. This is
  70. // by design: already-offline nodes get their HA entity toggled off,
  71. // which is idempotent.
  72. info!(
  73. "{}",
  74. fluent_i18n::t!("power-off-head", { "nodes" => targets.len() })
  75. );
  76. let mut handles = Vec::with_capacity(targets.len());
  77. for node in targets.iter().cloned() {
  78. let config = config.clone();
  79. handles.push(crate::locale::spawn_with_locale(async move {
  80. let cmd = "sudo shutdown now".to_string();
  81. let r = crate::ssh::run_ssh(&config, &node, cmd.clone(), false).await;
  82. (node, cmd, r)
  83. }));
  84. }
  85. let mut any_ssh_failed = false;
  86. for h in handles {
  87. let (node, cmd, r) = h.await?;
  88. if let Err(e) = r {
  89. any_ssh_failed = true;
  90. warn!(
  91. "{}",
  92. fluent_i18n::t!("power-off-shutdown-failed", { "name" => &node.name, "cmd" => &cmd, "error" => e.to_string() })
  93. );
  94. }
  95. }
  96. // 2) Wait so the OS actually shuts down before we cut power.
  97. let delay = config
  98. .home_assistant
  99. .as_ref()
  100. .map(|c| c.shutdown_delay_seconds)
  101. .unwrap_or(30);
  102. info!(
  103. "{}",
  104. fluent_i18n::t!("power-off-waiting", { "secs" => delay })
  105. );
  106. tokio::time::sleep(Duration::from_secs(delay)).await;
  107. // 3) HA turn_off: per-node entities, then cluster-level extras.
  108. // Mikrotik names are skipped here — they get a graceful RouterOS
  109. // API shutdown in step 4 (after HA, so the router dies last).
  110. let mikrotik_off: Vec<String> = if let Some(ha_cfg) = &config.home_assistant {
  111. let ha = HaClient::new(ha_cfg)?;
  112. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  113. toggle_entities(
  114. &ha,
  115. "turn_off",
  116. config.mikrotik.as_ref(),
  117. &targets,
  118. &extras,
  119. "cluster",
  120. )
  121. .await
  122. } else {
  123. info!("{}", fluent_i18n::t!("power-off-no-ha"));
  124. // No HA at all — still shut down any mikrotik device referenced.
  125. mikro::collect_off(config, &targets, &extras)
  126. };
  127. // 4) RouterOS graceful shutdown for each referenced device.
  128. mikro::shutdown_named(config, &mikrotik_off).await;
  129. if any_ssh_failed {
  130. warn!("{}", fluent_i18n::t!("power-off-ssh-failures"));
  131. }
  132. Ok(())
  133. }
  134. /// Flow for `power on`: HA `turn_on` for each node with an `entity`
  135. /// and each cluster-level `extra_entities`. No SSH needed — the smart
  136. /// plug handles waking the machine.
  137. /// Flow for `power on`: HA `turn_on` for each node with an `entity`
  138. /// Flow for `power on`: per-node plug decisions (`decide_on` + the caller's
  139. /// `stale` list), then cluster-level `extra_entities`. Returns one report
  140. /// line per node — the job surface (web job message, CLI log) tells what
  141. /// actually happened instead of a blanket "ok". No SSH needed — the smart
  142. /// plug (plus a cycle for stale-on plugs) wakes the machine.
  143. pub async fn power_on(
  144. config: &Config,
  145. cluster: &Cluster,
  146. node_names: &[String],
  147. dry_run: bool,
  148. stale: &[String],
  149. ) -> Result<Vec<String>> {
  150. let targets = select_targets(cluster, node_names, "power on")?;
  151. let Some(ha_cfg) = &config.home_assistant else {
  152. anyhow::bail!(
  153. "[home_assistant] section is required for `power on` \
  154. (the launcher has no other way to wake a node)"
  155. );
  156. };
  157. let ha = HaClient::new(ha_cfg)?;
  158. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  159. if dry_run {
  160. warn!("{}", fluent_i18n::t!("power-on-dryrun"));
  161. mikro::dry_on(config, &targets, &extra_ids(cluster));
  162. return Ok(Vec::new());
  163. }
  164. info!(
  165. "{}",
  166. fluent_i18n::t!("power-on-head", { "nodes" => targets.len() })
  167. );
  168. let mut report: Vec<String> = Vec::new();
  169. for node in &targets {
  170. let Some(entity) = &node.entity else {
  171. warn!(
  172. "{}",
  173. fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name })
  174. );
  175. report.push(fluent_i18n::t!("power-on-skip", { "name" => &node.name }));
  176. continue;
  177. };
  178. if mikro::device(config.mikrotik.as_ref(), entity).is_some() {
  179. // RouterOS devices are not HA plugs; wake via the relay as before
  180. toggle_one(
  181. &ha,
  182. "turn_on",
  183. config.mikrotik.as_ref(),
  184. entity,
  185. &node.name,
  186. &mut Vec::new(),
  187. )
  188. .await;
  189. report.push(fluent_i18n::t!("power-on-on", { "name" => &node.name }));
  190. continue;
  191. }
  192. let action = decide_on(
  193. ha.get_state(entity).await.as_deref(),
  194. stale.contains(&node.name),
  195. );
  196. match action {
  197. OnAction::TurnOn => {
  198. if let Err(e) = call(&ha, entity, "turn_on").await {
  199. warn!(
  200. "{}",
  201. fluent_i18n::t!("ha-call-failed", { "service" => "turn_on", "entity" => entity.clone(), "error" => e.to_string() })
  202. );
  203. report.push(fluent_i18n::t!("power-on-failed", { "name" => &node.name, "error" => e.to_string() }));
  204. } else {
  205. report.push(fluent_i18n::t!("power-on-on", { "name" => &node.name }));
  206. }
  207. }
  208. OnAction::Cycle => {
  209. warn!(
  210. "{}",
  211. fluent_i18n::t!("power-on-cycling", { "name" => &node.name, "entity" => entity.clone() })
  212. );
  213. let r1 = call(&ha, entity, "turn_off").await;
  214. // the relay opens asynchronously — wait until HA actually
  215. // reports off (≤10 s), then a settle dwell, then re-apply
  216. let mut saw_off = false;
  217. for _ in 0..20 {
  218. tokio::time::sleep(Duration::from_millis(500)).await;
  219. if ha.get_state(entity).await.as_deref() != Some("on") {
  220. saw_off = true;
  221. break;
  222. }
  223. }
  224. if !saw_off {
  225. warn!(
  226. "{}",
  227. fluent_i18n::t!("power-on-off-wait-timeout", { "name" => &node.name, "entity" => entity.clone() })
  228. );
  229. }
  230. // the PDU needs a real outage: a 2-3 s cut left the PSU
  231. // rail energized and the machine stayed in soft-off — 30 s
  232. // reliably re-triggers the power-on
  233. tokio::time::sleep(Duration::from_secs(30)).await;
  234. let r2 = call(&ha, entity, "turn_on").await;
  235. match (r1, r2) {
  236. (Ok(()), Ok(())) => {
  237. report.push(fluent_i18n::t!("power-on-cycled", { "name" => &node.name }))
  238. }
  239. (e1, e2) => {
  240. let e = e1
  241. .err()
  242. .or(e2.err())
  243. .unwrap_or_else(|| anyhow::anyhow!("cycle"));
  244. report.push(fluent_i18n::t!("power-on-failed", { "name" => &node.name, "error" => e.to_string() }));
  245. }
  246. }
  247. }
  248. OnAction::AlreadyOn => {
  249. warn!(
  250. "{}",
  251. fluent_i18n::t!("power-on-was-on", { "name" => &node.name })
  252. );
  253. report.push(fluent_i18n::t!("power-on-was-on", { "name" => &node.name }));
  254. }
  255. }
  256. }
  257. // cluster-level extras (fan / relay / fabric switch) as before
  258. let _ = toggle_entities(
  259. &ha,
  260. "turn_on",
  261. config.mikrotik.as_ref(),
  262. &[],
  263. &extra_ids(cluster),
  264. "cluster",
  265. )
  266. .await;
  267. Ok(report)
  268. }
  269. /// Flow for `power switch --from <src> --to <tgt>`:
  270. /// 1. Shut down + HA turn_off nodes/entities unique to `from`,
  271. /// 2. HA turn_on all nodes/entities in `to`.
  272. pub async fn power_switch(
  273. config: &Config,
  274. from: &Cluster,
  275. to: &Cluster,
  276. dry_run: bool,
  277. ) -> Result<()> {
  278. let to_names: std::collections::HashSet<&str> =
  279. to.nodes.iter().map(|n| n.name.as_str()).collect();
  280. let disable_nodes: Vec<Node> = from
  281. .nodes
  282. .iter()
  283. .filter(|n| !to_names.contains(n.name.as_str()))
  284. .cloned()
  285. .collect();
  286. let to_extras: std::collections::HashSet<&str> =
  287. to.extra_entities.iter().map(|e| e.entity()).collect();
  288. let disable_extras: Vec<String> = from
  289. .extra_entities
  290. .iter()
  291. .filter(|e| !to_extras.contains(e.entity()))
  292. .map(|e| e.entity().to_string())
  293. .collect();
  294. info!(
  295. "{}",
  296. fluent_i18n::t!("power-switch-head", { "from" => &from.name, "to" => &to.name, "nodes" => disable_nodes.len(), "entities" => disable_extras.len() })
  297. );
  298. if dry_run {
  299. mikro::dry_switch_off(config, &disable_nodes, &disable_extras);
  300. mikro::dry_switch_on(config, &to.nodes, &extra_ids(to));
  301. return Ok(());
  302. }
  303. // SSH shutdown nodes not in target
  304. let mut handles = Vec::with_capacity(disable_nodes.len());
  305. for node in disable_nodes.iter().cloned() {
  306. let c = config.clone();
  307. handles.push(crate::locale::spawn_with_locale(async move {
  308. (
  309. node.name.clone(),
  310. crate::ssh::run_ssh(&c, &node, "sudo shutdown now".into(), false).await,
  311. )
  312. }));
  313. }
  314. for h in handles {
  315. let (name, r) = h.await?;
  316. if let Err(e) = r {
  317. warn!(
  318. "{}",
  319. fluent_i18n::t!("power-switch-shutdown-failed", { "name" => &name, "error" => e.to_string() })
  320. );
  321. }
  322. }
  323. let delay = config
  324. .home_assistant
  325. .as_ref()
  326. .map(|c| c.shutdown_delay_seconds)
  327. .unwrap_or(30);
  328. if !disable_nodes.is_empty() {
  329. info!(
  330. "{}",
  331. fluent_i18n::t!("power-switch-waiting", { "secs" => delay })
  332. );
  333. tokio::time::sleep(Duration::from_secs(delay)).await;
  334. } else {
  335. // No nodes to shut down — only extra_entities differ. HA
  336. // turn_off / turn_on runs immediately without delay.
  337. }
  338. if let Some(ha_cfg) = &config.home_assistant {
  339. let ha = HaClient::new(ha_cfg)?;
  340. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  341. // Disable phase: per-node entities + extras unique to `from`.
  342. // Routed through `toggle_entities` (which validates + logs failures)
  343. // instead of ad-hoc loops with `let _ =` (which swallowed errors)
  344. // and dead `if dry_run` branches (dry-run returned at the top).
  345. let mikrotik_off = toggle_entities(
  346. &ha,
  347. "turn_off",
  348. config.mikrotik.as_ref(),
  349. &disable_nodes,
  350. &disable_extras,
  351. "from",
  352. )
  353. .await;
  354. // RouterOS devices named above: graceful API shutdown (after HA).
  355. mikro::shutdown_named(config, &mikrotik_off).await;
  356. info!(
  357. "{}",
  358. fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name })
  359. );
  360. let _ = toggle_entities(
  361. &ha,
  362. "turn_on",
  363. config.mikrotik.as_ref(),
  364. &to.nodes,
  365. &extra_ids(to),
  366. "to",
  367. )
  368. .await;
  369. } else {
  370. info!("{}", fluent_i18n::t!("power-switch-no-ha"));
  371. // No HA, but mikrotik devices named in `from` still get the
  372. // graceful API shutdown.
  373. mikro::shutdown_named(
  374. config,
  375. &mikro::collect_off(config, &disable_nodes, &disable_extras),
  376. )
  377. .await;
  378. }
  379. Ok(())
  380. }
  381. pub(crate) fn select_targets(
  382. cluster: &Cluster,
  383. node_names: &[String],
  384. op: &str,
  385. ) -> Result<Vec<Node>> {
  386. let targets: Vec<Node> = if node_names.is_empty() {
  387. cluster.nodes.clone()
  388. } else {
  389. let mut found: Vec<Node> = Vec::new();
  390. let mut missing: Vec<String> = Vec::new();
  391. for nn in node_names {
  392. if let Some(node) = cluster.nodes.iter().find(|n| &n.name == nn) {
  393. found.push(node.clone());
  394. } else {
  395. missing.push(nn.clone());
  396. }
  397. }
  398. if !missing.is_empty() {
  399. anyhow::bail!(
  400. "{}: node(s) {} not found in cluster '{}'",
  401. op,
  402. missing.join(", "),
  403. cluster.name
  404. );
  405. }
  406. found
  407. };
  408. if targets.is_empty() {
  409. anyhow::bail!("{}: cluster '{}' has no nodes", op, cluster.name);
  410. }
  411. Ok(targets)
  412. }
  413. #[cfg(test)]
  414. mod tests;