| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147 |
- //! End-effector traits — abstract operations that touch real hardware
- //! (SSH, Home Assistant, Docker). Control logic in [`cluster_state`]
- //! and [`orchestrate`] works with these traits so it can be tested
- //! without live infrastructure.
- use anyhow::Result;
- use crate::cluster_state::ClusterState;
- use crate::config::{Cluster, Config, Node};
- /// Probe nodes and return their aggregate state.
- pub trait NodeProber {
- /// Probe all unique nodes across the config.
- fn probe(&self, config: &Config) -> impl std::future::Future<Output = ClusterState> + Send;
- }
- /// Power on/off smart plugs via Home Assistant.
- ///
- /// Intentionally models only the **power-on** path (`entity_is_on` /
- /// `turn_on`): orchestrate's `prepare_cluster` auto-powers-on a cluster
- /// when nodes are unreachable, and that is the only place the trait is
- /// used. `power off` is a direct user command (`power.rs`) that talks to
- /// `HaClient` directly — it does not flow through orchestrate, so it has
- /// no need of the trait abstraction. If orchestrate ever needs to power
- /// off, add `turn_off` here and route `power_off` through it.
- pub trait PowerController {
- /// Check whether a HA entity is currently in state `"on"`.
- fn entity_is_on(&self, entity: &str) -> impl std::future::Future<Output = Option<bool>> + Send;
- /// Turn on a HA entity (power on the node/switch).
- fn turn_on(&self, entity: &str) -> impl std::future::Future<Output = Result<()>> + Send;
- }
- /// Ping a node via SSH to test reachability.
- pub trait NodePinger {
- /// Whether the node accepts SSH connections.
- fn is_reachable(
- &self,
- config: &Config,
- node: &Node,
- ) -> impl std::future::Future<Output = bool> + Send;
- }
- // ---------------------------------------------------------------------------
- // Real implementations
- // ---------------------------------------------------------------------------
- use crate::home_assistant::Client as HaClient;
- use crate::home_assistant::warn_if_token_looks_short;
- use crate::ssh::run_ssh;
- use tracing::warn;
- pub struct SshProber;
- impl NodeProber for SshProber {
- async fn probe(&self, config: &Config) -> ClusterState {
- ClusterState::probe(config).await
- }
- }
- pub struct HaPowerController {
- ha: HaClient,
- config: Config,
- }
- impl HaPowerController {
- pub fn new(ha: HaClient, config: Config) -> Self {
- Self { ha, config }
- }
- }
- impl PowerController for HaPowerController {
- async fn entity_is_on(&self, entity: &str) -> Option<bool> {
- // `[mikrotik]` device: liveness through the RouterOS API login
- // (logged in = on; unreachable/rejected = off, like a dead switch).
- if let Some(dev) = self
- .config
- .mikrotik
- .as_ref()
- .and_then(|m| m.devices.iter().find(|d| d.name == entity))
- {
- return Some(crate::mikrotik::ping_device(dev).await.is_ok());
- }
- self.ha.get_state(entity).await.map(|s| s == "on")
- }
- async fn turn_on(&self, entity: &str) -> Result<()> {
- // RouterOS has no power-ON API: route through the device's
- // `on_entity` HA relay if configured; otherwise the operator plugs
- // it in manually (same semantics as `power on`), never an error.
- if let Some(dev) = self
- .config
- .mikrotik
- .as_ref()
- .and_then(|m| m.devices.iter().find(|d| d.name == entity))
- {
- match &dev.on_entity {
- Some(relay) => self.ha.turn_on(relay).await?,
- None => {
- warn!("RouterOS {entity} has no on_entity relay — power it on manually");
- }
- }
- return Ok(());
- }
- self.ha.turn_on(entity).await
- }
- }
- pub struct SshNodePinger;
- impl NodePinger for SshNodePinger {
- async fn is_reachable(&self, config: &Config, node: &Node) -> bool {
- run_ssh(config, node, "echo ok".into(), false).await.is_ok()
- }
- }
- /// Build a [`HaPowerController`] from config, returning `None` if HA is
- /// not configured.
- pub fn ha_controller_from_config(config: &Config) -> Option<HaPowerController> {
- let ha_cfg = config.home_assistant.as_ref()?;
- let ha = match HaClient::new(ha_cfg) {
- Ok(c) => c,
- Err(e) => {
- warn!("Failed to construct HA client: {}", e);
- return None;
- }
- };
- warn_if_token_looks_short(&ha_cfg.token);
- Some(HaPowerController::new(ha, config.clone()))
- }
- /// Collect all entity IDs (per-node + cluster extras) for a cluster.
- pub fn cluster_entities(cluster: &Cluster) -> Vec<String> {
- let mut entities: Vec<String> = Vec::new();
- for node in &cluster.nodes {
- if let Some(entity) = &node.entity {
- entities.push(entity.clone());
- }
- }
- entities.extend(
- cluster
- .extra_entities
- .iter()
- .map(|e| e.entity().to_string()),
- );
- entities
- }
|