power.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  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::home_assistant::split_entity;
  15. use crate::config::{Cluster, Config, MikrotikConfig, Node};
  16. use crate::home_assistant::Client as HaClient;
  17. mod mikro;
  18. /// Flow for `power off`: SSH shutdown + wait + HA `turn_off`.
  19. pub async fn power_off(
  20. config: &Config,
  21. cluster: &Cluster,
  22. node_names: &[String],
  23. dry_run: bool,
  24. ) -> Result<()> {
  25. let targets = select_targets(cluster, node_names, "power off")?;
  26. if dry_run {
  27. warn!("{}", fluent_i18n::t!("power-off-dryrun"));
  28. mikro::dry_off(config, &targets, &extra_ids(cluster));
  29. return Ok(());
  30. }
  31. // 1) SSH shutdown every target in parallel.
  32. // NOTE: This is fire-and-forget — if a node is unreachable, its
  33. // SSH failure is logged but the flow continues. The HA `turn_off`
  34. // still fires for all targets regardless of SSH success. This is
  35. // by design: already-offline nodes get their HA entity toggled off,
  36. // which is idempotent.
  37. info!(
  38. "{}",
  39. fluent_i18n::t!("power-off-head", { "nodes" => targets.len() })
  40. );
  41. let mut handles = Vec::with_capacity(targets.len());
  42. for node in targets.iter().cloned() {
  43. let config = config.clone();
  44. handles.push(crate::locale::spawn_with_locale(async move {
  45. let cmd = "sudo shutdown now".to_string();
  46. let r = crate::ssh::run_ssh(&config, &node, cmd.clone(), false).await;
  47. (node, cmd, r)
  48. }));
  49. }
  50. let mut any_ssh_failed = false;
  51. for h in handles {
  52. let (node, cmd, r) = h.await?;
  53. if let Err(e) = r {
  54. any_ssh_failed = true;
  55. warn!(
  56. "{}",
  57. fluent_i18n::t!("power-off-shutdown-failed", { "name" => &node.name, "cmd" => &cmd, "error" => e.to_string() })
  58. );
  59. }
  60. }
  61. // 2) Wait so the OS actually shuts down before we cut power.
  62. let delay = config
  63. .home_assistant
  64. .as_ref()
  65. .map(|c| c.shutdown_delay_seconds)
  66. .unwrap_or(30);
  67. info!(
  68. "{}",
  69. fluent_i18n::t!("power-off-waiting", { "secs" => delay })
  70. );
  71. tokio::time::sleep(Duration::from_secs(delay)).await;
  72. // 3) HA turn_off: per-node entities, then cluster-level extras.
  73. // Mikrotik names are skipped here — they get a graceful RouterOS
  74. // API shutdown in step 4 (after HA, so the router dies last).
  75. let mikrotik_off: Vec<String> = if let Some(ha_cfg) = &config.home_assistant {
  76. let ha = HaClient::new(ha_cfg)?;
  77. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  78. toggle_entities(
  79. &ha,
  80. "turn_off",
  81. config.mikrotik.as_ref(),
  82. &targets,
  83. &extra_ids(cluster),
  84. "cluster",
  85. )
  86. .await
  87. } else {
  88. info!("{}", fluent_i18n::t!("power-off-no-ha"));
  89. // No HA at all — still shut down any mikrotik device referenced.
  90. mikro::collect_off(config, &targets, &extra_ids(cluster))
  91. };
  92. // 4) RouterOS graceful shutdown for each referenced device.
  93. mikro::shutdown_named(config, &mikrotik_off).await;
  94. if any_ssh_failed {
  95. warn!("{}", fluent_i18n::t!("power-off-ssh-failures"));
  96. }
  97. Ok(())
  98. }
  99. /// Flow for `power on`: HA `turn_on` for each node with an `entity`
  100. /// and each cluster-level `extra_entities`. No SSH needed — the smart
  101. /// plug handles waking the machine.
  102. pub async fn power_on(
  103. config: &Config,
  104. cluster: &Cluster,
  105. node_names: &[String],
  106. dry_run: bool,
  107. ) -> Result<()> {
  108. let targets = select_targets(cluster, node_names, "power on")?;
  109. let Some(ha_cfg) = &config.home_assistant else {
  110. anyhow::bail!(
  111. "[home_assistant] section is required for `power on` \
  112. (the launcher has no other way to wake a node)"
  113. );
  114. };
  115. let ha = HaClient::new(ha_cfg)?;
  116. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  117. if dry_run {
  118. warn!("{}", fluent_i18n::t!("power-on-dryrun"));
  119. mikro::dry_on(config, &targets, &extra_ids(cluster));
  120. return Ok(());
  121. }
  122. info!(
  123. "{}",
  124. fluent_i18n::t!("power-on-head", { "nodes" => targets.len() })
  125. );
  126. let _ = toggle_entities(
  127. &ha,
  128. "turn_on",
  129. config.mikrotik.as_ref(),
  130. &targets,
  131. &extra_ids(cluster),
  132. "cluster",
  133. )
  134. .await;
  135. Ok(())
  136. }
  137. /// Entity ids of a cluster's `extra_entities` (both forms).
  138. fn extra_ids(cluster: &Cluster) -> Vec<String> {
  139. cluster
  140. .extra_entities
  141. .iter()
  142. .map(|e| e.entity().to_string())
  143. .collect()
  144. }
  145. /// Handle one entity id for a power toggle: dispatch `[mikrotik]`
  146. /// devices to the RouterOS API path (or their HA relay), everything else
  147. /// to HA with `domain.name` validation.
  148. async fn toggle_one(
  149. ha: &HaClient,
  150. service: &'static str,
  151. mikrotik: Option<&MikrotikConfig>,
  152. entity: &str,
  153. name_for_log: &str,
  154. mikrotik_off: &mut Vec<String>,
  155. ) {
  156. // Mikrotik devices are addressed by exact name — dispatch before any
  157. // HA validation (a router name could otherwise parse as a fictional
  158. // `mikrotik.<x>` HA entity).
  159. if let Some(dev) = mikro::device(mikrotik, entity) {
  160. if service == "turn_off" {
  161. if !mikrotik_off.iter().any(|n| n == &dev.name) {
  162. mikrotik_off.push(dev.name.clone());
  163. }
  164. info!(
  165. "{}",
  166. fluent_i18n::t!("power-off-mikrotik-skip", { "entity" => entity.to_string(), "address" => dev.address.clone() })
  167. );
  168. return;
  169. }
  170. if let Some(relay) = &dev.on_entity {
  171. if let Err(e) = call(ha, relay, service).await {
  172. warn!(
  173. "{}",
  174. fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => relay.clone(), "label" => name_for_log.to_string(), "error" => e.to_string() })
  175. );
  176. }
  177. } else {
  178. warn!(
  179. "{}",
  180. fluent_i18n::t!("power-on-mikrotik-manual", { "name" => dev.name.clone() })
  181. );
  182. }
  183. return;
  184. }
  185. if let Err(e) = split_entity(entity) {
  186. warn!(
  187. "{}",
  188. fluent_i18n::t!("ha-invalid-entity", { "name" => name_for_log.to_string(), "entity" => entity.to_string(), "error" => e.to_string() })
  189. );
  190. return;
  191. }
  192. if let Err(e) = call(ha, entity, service).await {
  193. warn!(
  194. "{}",
  195. fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity.to_string(), "error" => e.to_string() })
  196. );
  197. }
  198. }
  199. /// Call HA `service` for each per-node entity, then each cluster-level
  200. /// `extra_entities`. Entities that name a `[mikrotik]` device are NOT
  201. /// sent to HA: for `turn_off` their device names are returned (the caller
  202. /// does the RouterOS API shutdown afterwards); for `turn_on` an optional
  203. /// `on_entity` relay is toggled in HA instead, or a manual-power warning
  204. /// is logged.
  205. async fn toggle_entities(
  206. ha: &HaClient,
  207. service: &'static str,
  208. mikrotik: Option<&MikrotikConfig>,
  209. targets: &[Node],
  210. extras: &[String],
  211. extras_label: &'static str,
  212. ) -> Vec<String> {
  213. let mut mikrotik_off = Vec::new();
  214. for node in targets {
  215. if let Some(entity) = &node.entity {
  216. toggle_one(ha, service, mikrotik, entity, &node.name, &mut mikrotik_off).await;
  217. } else {
  218. warn!(
  219. "{}",
  220. fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name })
  221. );
  222. }
  223. }
  224. for extra in extras {
  225. toggle_one(
  226. ha,
  227. service,
  228. mikrotik,
  229. extra,
  230. extras_label,
  231. &mut mikrotik_off,
  232. )
  233. .await;
  234. }
  235. mikrotik_off
  236. }
  237. /// Dispatch to the right HA service. Inlined because each call only
  238. /// differs by `service`, and inlining keeps the trait surface small.
  239. async fn call(ha: &HaClient, entity: &str, service: &'static str) -> Result<()> {
  240. match service {
  241. "turn_off" => ha.turn_off(entity).await,
  242. "turn_on" => ha.turn_on(entity).await,
  243. other => anyhow::bail!("unsupported HA service '{}'", other),
  244. }
  245. }
  246. /// Flow for `power switch --from <src> --to <tgt>`:
  247. /// 1. Shut down + HA turn_off nodes/entities unique to `from`,
  248. /// 2. HA turn_on all nodes/entities in `to`.
  249. pub async fn power_switch(
  250. config: &Config,
  251. from: &Cluster,
  252. to: &Cluster,
  253. dry_run: bool,
  254. ) -> Result<()> {
  255. let to_names: std::collections::HashSet<&str> =
  256. to.nodes.iter().map(|n| n.name.as_str()).collect();
  257. let disable_nodes: Vec<Node> = from
  258. .nodes
  259. .iter()
  260. .filter(|n| !to_names.contains(n.name.as_str()))
  261. .cloned()
  262. .collect();
  263. let to_extras: std::collections::HashSet<&str> =
  264. to.extra_entities.iter().map(|e| e.entity()).collect();
  265. let disable_extras: Vec<String> = from
  266. .extra_entities
  267. .iter()
  268. .filter(|e| !to_extras.contains(e.entity()))
  269. .map(|e| e.entity().to_string())
  270. .collect();
  271. info!(
  272. "{}",
  273. fluent_i18n::t!("power-switch-head", { "from" => &from.name, "to" => &to.name, "nodes" => disable_nodes.len(), "entities" => disable_extras.len() })
  274. );
  275. if dry_run {
  276. mikro::dry_switch_off(config, &disable_nodes, &disable_extras);
  277. mikro::dry_switch_on(config, &to.nodes, &extra_ids(to));
  278. return Ok(());
  279. }
  280. // SSH shutdown nodes not in target
  281. let mut handles = Vec::with_capacity(disable_nodes.len());
  282. for node in disable_nodes.iter().cloned() {
  283. let c = config.clone();
  284. handles.push(crate::locale::spawn_with_locale(async move {
  285. (
  286. node.name.clone(),
  287. crate::ssh::run_ssh(&c, &node, "sudo shutdown now".into(), false).await,
  288. )
  289. }));
  290. }
  291. for h in handles {
  292. let (name, r) = h.await?;
  293. if let Err(e) = r {
  294. warn!(
  295. "{}",
  296. fluent_i18n::t!("power-switch-shutdown-failed", { "name" => &name, "error" => e.to_string() })
  297. );
  298. }
  299. }
  300. let delay = config
  301. .home_assistant
  302. .as_ref()
  303. .map(|c| c.shutdown_delay_seconds)
  304. .unwrap_or(30);
  305. if !disable_nodes.is_empty() {
  306. info!(
  307. "{}",
  308. fluent_i18n::t!("power-switch-waiting", { "secs" => delay })
  309. );
  310. tokio::time::sleep(Duration::from_secs(delay)).await;
  311. } else {
  312. // No nodes to shut down — only extra_entities differ. HA
  313. // turn_off / turn_on runs immediately without delay.
  314. }
  315. if let Some(ha_cfg) = &config.home_assistant {
  316. let ha = HaClient::new(ha_cfg)?;
  317. crate::home_assistant::warn_if_token_looks_short(&ha_cfg.token);
  318. // Disable phase: per-node entities + extras unique to `from`.
  319. // Routed through `toggle_entities` (which validates + logs failures)
  320. // instead of ad-hoc loops with `let _ =` (which swallowed errors)
  321. // and dead `if dry_run` branches (dry-run returned at the top).
  322. let mikrotik_off = toggle_entities(
  323. &ha,
  324. "turn_off",
  325. config.mikrotik.as_ref(),
  326. &disable_nodes,
  327. &disable_extras,
  328. "from",
  329. )
  330. .await;
  331. // RouterOS devices named above: graceful API shutdown (after HA).
  332. mikro::shutdown_named(config, &mikrotik_off).await;
  333. info!(
  334. "{}",
  335. fluent_i18n::t!("power-switch-powering-on", { "nodes" => to.nodes.len(), "entities" => to.extra_entities.len(), "to" => &to.name })
  336. );
  337. let _ = toggle_entities(
  338. &ha,
  339. "turn_on",
  340. config.mikrotik.as_ref(),
  341. &to.nodes,
  342. &extra_ids(to),
  343. "to",
  344. )
  345. .await;
  346. } else {
  347. info!("{}", fluent_i18n::t!("power-switch-no-ha"));
  348. // No HA, but mikrotik devices named in `from` still get the
  349. // graceful API shutdown.
  350. mikro::shutdown_named(
  351. config,
  352. &mikro::collect_off(config, &disable_nodes, &disable_extras),
  353. )
  354. .await;
  355. }
  356. Ok(())
  357. }
  358. fn select_targets(cluster: &Cluster, node_names: &[String], op: &str) -> Result<Vec<Node>> {
  359. let targets: Vec<Node> = if node_names.is_empty() {
  360. cluster.nodes.clone()
  361. } else {
  362. let mut found: Vec<Node> = Vec::new();
  363. let mut missing: Vec<String> = Vec::new();
  364. for nn in node_names {
  365. if let Some(node) = cluster.nodes.iter().find(|n| &n.name == nn) {
  366. found.push(node.clone());
  367. } else {
  368. missing.push(nn.clone());
  369. }
  370. }
  371. if !missing.is_empty() {
  372. anyhow::bail!(
  373. "{}: node(s) {} not found in cluster '{}'",
  374. op,
  375. missing.join(", "),
  376. cluster.name
  377. );
  378. }
  379. found
  380. };
  381. if targets.is_empty() {
  382. anyhow::bail!("{}: cluster '{}' has no nodes", op, cluster.name);
  383. }
  384. Ok(targets)
  385. }
  386. #[cfg(test)]
  387. mod tests;