| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- //! 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,
- }
- impl HaPowerController {
- pub fn new(ha: HaClient) -> Self {
- Self { ha }
- }
- }
- impl PowerController for HaPowerController {
- async fn entity_is_on(&self, entity: &str) -> Option<bool> {
- self.ha.get_state(entity).await.map(|s| s == "on")
- }
- async fn turn_on(&self, entity: &str) -> Result<()> {
- 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))
- }
- /// 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
- }
|