dora-cli 1.0.0-rc.2

`dora` goal is to be a low latency, composable, and distributed data flow.
use dora_message::{cli_to_coordinator::ControlRequest, coordinator_to_cli::DaemonInfo};
use eyre::Context;
use std::collections::BTreeMap;

use crate::{
    command::Executable,
    common::{expect_reply, send_control_request},
    ws_client::WsSession,
};

use self::config::MachineConfig;

pub mod config;
mod down;
mod install;
mod restart;
mod status;
mod uninstall;
mod up;
mod upgrade;

/// Manage a multi-machine cluster.
#[derive(Debug, clap::Subcommand)]
pub enum Cluster {
    Up(up::Up),
    Status(status::Status),
    Down(down::Down),
    Install(install::Install),
    Uninstall(uninstall::Uninstall),
    Upgrade(upgrade::Upgrade),
    Restart(restart::Restart),
}

impl Executable for Cluster {
    fn execute(self) -> eyre::Result<()> {
        match self {
            Cluster::Up(cmd) => cmd.execute(),
            Cluster::Status(cmd) => cmd.execute(),
            Cluster::Down(cmd) => cmd.execute(),
            Cluster::Install(cmd) => cmd.execute(),
            Cluster::Uninstall(cmd) => cmd.execute(),
            Cluster::Upgrade(cmd) => cmd.execute(),
            Cluster::Restart(cmd) => cmd.execute(),
        }
    }
}

pub(crate) fn query_connected_daemons(session: &WsSession) -> eyre::Result<Vec<DaemonInfo>> {
    let reply = send_control_request(session, &ControlRequest::ConnectedMachines)?;
    Ok(expect_reply!(reply, ConnectedDaemons(daemons))?)
}

// ---------------------------------------------------------------------------
// Shared SSH helpers
// ---------------------------------------------------------------------------

/// Format the SSH target string from a machine config.
pub(super) fn ssh_target(machine: &MachineConfig) -> String {
    match &machine.user {
        Some(user) => format!("{user}@{}", machine.host),
        None => machine.host.clone(),
    }
}

/// Format the `--labels key=val,key=val` argument string.
pub(super) fn format_labels_arg(labels: &BTreeMap<String, String>) -> String {
    if labels.is_empty() {
        String::new()
    } else {
        let pairs: Vec<String> = labels.iter().map(|(k, v)| format!("{k}={v}")).collect();
        format!(" --labels {}", pairs.join(","))
    }
}

/// Format the `--local-listen-port <p>` argument string.
pub(super) fn format_daemon_port_arg(daemon_port: Option<u16>) -> String {
    match daemon_port {
        Some(p) => format!(" --local-listen-port {p}"),
        None => String::new(),
    }
}

/// Format the `--zenoh-peer <ep>` argument string.
pub(super) fn format_zenoh_peer_arg(zenoh_peer: Option<&str>) -> String {
    match zenoh_peer {
        Some(ep) => format!(" --zenoh-peer {ep}"),
        None => String::new(),
    }
}

/// Run a command on a remote machine via SSH. Returns whether it succeeded.
pub(super) fn run_ssh(target: &str, port: Option<u16>, cmd: &str) -> eyre::Result<bool> {
    let mut command = std::process::Command::new("ssh");
    command.args([
        "-o",
        "BatchMode=yes",
        "-o",
        "ConnectTimeout=10",
        "-o",
        "StrictHostKeyChecking=accept-new",
    ]);
    if let Some(p) = port {
        command.args(["-p", &p.to_string()]);
    }
    command.args([target, cmd]);
    let status = command
        .status()
        .with_context(|| format!("failed to run ssh to {target}"))?;
    Ok(status.success())
}

/// Record an SSH result into a failure list. Prints OK on success, FAILED on error.
pub(super) fn record_ssh_result(
    failures: &mut Vec<(String, String)>,
    machine_id: &str,
    result: eyre::Result<bool>,
    ok_msg: &str,
) {
    match result {
        Ok(true) => println!("  OK: {ok_msg}"),
        Ok(false) => {
            let msg = "ssh command failed".to_string();
            eprintln!("  FAILED: {msg}");
            failures.push((machine_id.to_owned(), msg));
        }
        Err(err) => {
            let msg = format!("{err}");
            eprintln!("  FAILED: {msg}");
            failures.push((machine_id.to_owned(), msg));
        }
    }
}

/// Print a summary of successes and failures after a batch SSH operation.
pub(super) fn print_summary(action: &str, total: usize, failures: &[(String, String)]) {
    if failures.is_empty() {
        println!("All {total} {action}");
    } else {
        println!("{}/{total} {action}", total - failures.len());
        for (id, reason) in failures {
            eprintln!("  {id}: {reason}");
        }
    }
}