|
@@ -13,11 +13,13 @@ use anyhow::Result;
|
|
|
use std::time::Duration;
|
|
use std::time::Duration;
|
|
|
use tracing::{info, warn};
|
|
use tracing::{info, warn};
|
|
|
|
|
|
|
|
-use crate::home_assistant::split_entity;
|
|
|
|
|
-
|
|
|
|
|
-use crate::config::{Cluster, Config, MikrotikConfig, Node};
|
|
|
|
|
|
|
+use crate::config::{Cluster, Config, Node};
|
|
|
use crate::home_assistant::Client as HaClient;
|
|
use crate::home_assistant::Client as HaClient;
|
|
|
|
|
|
|
|
|
|
+pub mod plug;
|
|
|
|
|
+pub mod toggle;
|
|
|
|
|
+pub use plug::{OnAction, decide_on, stale_nodes};
|
|
|
|
|
+use toggle::{call, extra_ids, toggle_entities, toggle_one};
|
|
|
mod mikro;
|
|
mod mikro;
|
|
|
|
|
|
|
|
/// Flow for `power off`: SSH shutdown + wait + HA `turn_off`.
|
|
/// Flow for `power off`: SSH shutdown + wait + HA `turn_off`.
|
|
@@ -112,53 +114,12 @@ pub async fn power_off(
|
|
|
/// Flow for `power on`: HA `turn_on` for each node with an `entity`
|
|
/// Flow for `power on`: HA `turn_on` for each node with an `entity`
|
|
|
/// and each cluster-level `extra_entities`. No SSH needed — the smart
|
|
/// and each cluster-level `extra_entities`. No SSH needed — the smart
|
|
|
/// plug handles waking the machine.
|
|
/// plug handles waking the machine.
|
|
|
-/// What `power on` should do with one node's plug.
|
|
|
|
|
-#[derive(Debug, PartialEq, Eq)]
|
|
|
|
|
-pub(crate) enum OnAction {
|
|
|
|
|
- /// Plug off (or state unknown) — plain `turn_on`.
|
|
|
|
|
- TurnOn,
|
|
|
|
|
- /// Plug reads on but the node is believed down — cut and re-apply
|
|
|
|
|
- /// (a graceful shutdown leaves the plug on while the machine sits in
|
|
|
|
|
- /// soft-off, so a plain `turn_on` would be a silent no-op).
|
|
|
|
|
- Cycle,
|
|
|
|
|
- /// Plug on and the node is believed up — leave it alone.
|
|
|
|
|
- AlreadyOn,
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// SGR-free decision: pure so the semantics are testable.
|
|
|
|
|
-pub(crate) fn decide_on(state: Option<&str>, node_is_down: bool) -> OnAction {
|
|
|
|
|
- match state {
|
|
|
|
|
- Some("on") if node_is_down => OnAction::Cycle,
|
|
|
|
|
- Some("on") => OnAction::AlreadyOn,
|
|
|
|
|
- _ => OnAction::TurnOn,
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// TCP connect to the node's SSH port (1.5 s) — a soft-off machine refuses
|
|
|
|
|
-/// or times out, a live one accepts. Not an SSH handshake: no auth, cheap.
|
|
|
|
|
-pub(crate) async fn stale_nodes(targets: &[Node]) -> Vec<String> {
|
|
|
|
|
- let mut out = Vec::new();
|
|
|
|
|
- for node in targets {
|
|
|
|
|
- let (host, port) = match node.ssh_ip.rsplit_once(':') {
|
|
|
|
|
- Some((h, p)) if p.parse::<u16>().is_ok() => (h.to_string(), p.parse().unwrap()),
|
|
|
|
|
- _ => (node.ssh_ip.clone(), 22),
|
|
|
|
|
- };
|
|
|
|
|
- let attempt = tokio::time::timeout(
|
|
|
|
|
- Duration::from_millis(1500),
|
|
|
|
|
- tokio::net::TcpStream::connect((host.as_str(), port)),
|
|
|
|
|
- )
|
|
|
|
|
- .await;
|
|
|
|
|
- if !matches!(attempt, Ok(Ok(_))) {
|
|
|
|
|
- out.push(node.name.clone());
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- out
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
|
|
+/// Flow for `power on`: HA `turn_on` for each node with an `entity`
|
|
|
/// Flow for `power on`: per-node plug decisions (`decide_on` + the caller's
|
|
/// Flow for `power on`: per-node plug decisions (`decide_on` + the caller's
|
|
|
/// `stale` list), then cluster-level `extra_entities`. Returns one report
|
|
/// `stale` list), then cluster-level `extra_entities`. Returns one report
|
|
|
/// line per node — the job surface (web job message, CLI log) tells what
|
|
/// line per node — the job surface (web job message, CLI log) tells what
|
|
|
-/// actually happened instead of a blanket "ok".
|
|
|
|
|
|
|
+/// actually happened instead of a blanket "ok". No SSH needed — the smart
|
|
|
|
|
+/// plug (plus a cycle for stale-on plugs) wakes the machine.
|
|
|
pub async fn power_on(
|
|
pub async fn power_on(
|
|
|
config: &Config,
|
|
config: &Config,
|
|
|
cluster: &Cluster,
|
|
cluster: &Cluster,
|
|
@@ -289,119 +250,6 @@ pub async fn power_on(
|
|
|
Ok(report)
|
|
Ok(report)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Entity ids of a cluster's `extra_entities` (both forms).
|
|
|
|
|
-fn extra_ids(cluster: &Cluster) -> Vec<String> {
|
|
|
|
|
- cluster
|
|
|
|
|
- .extra_entities
|
|
|
|
|
- .iter()
|
|
|
|
|
- .map(|e| e.entity().to_string())
|
|
|
|
|
- .collect()
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// Handle one entity id for a power toggle: dispatch `[mikrotik]`
|
|
|
|
|
-/// devices to the RouterOS API path (or their HA relay), everything else
|
|
|
|
|
-/// to HA with `domain.name` validation.
|
|
|
|
|
-async fn toggle_one(
|
|
|
|
|
- ha: &HaClient,
|
|
|
|
|
- service: &'static str,
|
|
|
|
|
- mikrotik: Option<&MikrotikConfig>,
|
|
|
|
|
- entity: &str,
|
|
|
|
|
- name_for_log: &str,
|
|
|
|
|
- mikrotik_off: &mut Vec<String>,
|
|
|
|
|
-) {
|
|
|
|
|
- // Mikrotik devices are addressed by exact name — dispatch before any
|
|
|
|
|
- // HA validation (a router name could otherwise parse as a fictional
|
|
|
|
|
- // `mikrotik.<x>` HA entity).
|
|
|
|
|
- if let Some(dev) = mikro::device(mikrotik, entity) {
|
|
|
|
|
- if service == "turn_off" {
|
|
|
|
|
- if !mikrotik_off.iter().any(|n| n == &dev.name) {
|
|
|
|
|
- mikrotik_off.push(dev.name.clone());
|
|
|
|
|
- }
|
|
|
|
|
- info!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("power-off-mikrotik-skip", { "entity" => entity.to_string(), "address" => dev.address.clone() })
|
|
|
|
|
- );
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if let Some(relay) = &dev.on_entity {
|
|
|
|
|
- if let Err(e) = call(ha, relay, service).await {
|
|
|
|
|
- warn!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("ha-extra-failed", { "service" => service, "extra" => relay.clone(), "label" => name_for_log.to_string(), "error" => e.to_string() })
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- warn!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("power-on-mikrotik-manual", { "name" => dev.name.clone() })
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if let Err(e) = split_entity(entity) {
|
|
|
|
|
- warn!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("ha-invalid-entity", { "name" => name_for_log.to_string(), "entity" => entity.to_string(), "error" => e.to_string() })
|
|
|
|
|
- );
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if let Err(e) = call(ha, entity, service).await {
|
|
|
|
|
- warn!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("ha-call-failed", { "service" => service, "entity" => entity.to_string(), "error" => e.to_string() })
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// Call HA `service` for each per-node entity, then each cluster-level
|
|
|
|
|
-/// `extra_entities`. Entities that name a `[mikrotik]` device are NOT
|
|
|
|
|
-/// sent to HA: for `turn_off` their device names are returned (the caller
|
|
|
|
|
-/// does the RouterOS API shutdown afterwards); for `turn_on` an optional
|
|
|
|
|
-/// `on_entity` relay is toggled in HA instead, or a manual-power warning
|
|
|
|
|
-/// is logged.
|
|
|
|
|
-async fn toggle_entities(
|
|
|
|
|
- ha: &HaClient,
|
|
|
|
|
- service: &'static str,
|
|
|
|
|
- mikrotik: Option<&MikrotikConfig>,
|
|
|
|
|
- targets: &[Node],
|
|
|
|
|
- extras: &[String],
|
|
|
|
|
- extras_label: &'static str,
|
|
|
|
|
-) -> Vec<String> {
|
|
|
|
|
- let mut mikrotik_off = Vec::new();
|
|
|
|
|
- for node in targets {
|
|
|
|
|
- if let Some(entity) = &node.entity {
|
|
|
|
|
- toggle_one(ha, service, mikrotik, entity, &node.name, &mut mikrotik_off).await;
|
|
|
|
|
- } else {
|
|
|
|
|
- warn!(
|
|
|
|
|
- "{}",
|
|
|
|
|
- fluent_i18n::t!("ha-skip-no-entity", { "name" => &node.name })
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- for extra in extras {
|
|
|
|
|
- toggle_one(
|
|
|
|
|
- ha,
|
|
|
|
|
- service,
|
|
|
|
|
- mikrotik,
|
|
|
|
|
- extra,
|
|
|
|
|
- extras_label,
|
|
|
|
|
- &mut mikrotik_off,
|
|
|
|
|
- )
|
|
|
|
|
- .await;
|
|
|
|
|
- }
|
|
|
|
|
- mikrotik_off
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-/// Dispatch to the right HA service. Inlined because each call only
|
|
|
|
|
-/// differs by `service`, and inlining keeps the trait surface small.
|
|
|
|
|
-async fn call(ha: &HaClient, entity: &str, service: &'static str) -> Result<()> {
|
|
|
|
|
- match service {
|
|
|
|
|
- "turn_off" => ha.turn_off(entity).await,
|
|
|
|
|
- "turn_on" => ha.turn_on(entity).await,
|
|
|
|
|
- other => anyhow::bail!("unsupported HA service '{}'", other),
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
/// Flow for `power switch --from <src> --to <tgt>`:
|
|
/// Flow for `power switch --from <src> --to <tgt>`:
|
|
|
/// 1. Shut down + HA turn_off nodes/entities unique to `from`,
|
|
/// 1. Shut down + HA turn_off nodes/entities unique to `from`,
|
|
|
/// 2. HA turn_on all nodes/entities in `to`.
|
|
/// 2. HA turn_on all nodes/entities in `to`.
|