power.rs 13 KB

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