isothrum-cli 2.1.1

isothrum: run what Isoloom generates from the command line.
//! Each command, on a runner the caller built.

use isothrum_runner::{AccessPick, Endpoint, Events, Occupied, Op, Park, RunName, RunOptions, Runner, Running, ShellCommand};

use std::borrow::Borrow;

use crate::args::{Cli, Command, Lifecycle, Mode, OpArgs, RunArgs, RunnerCommand};
use crate::error::{Error, Result};
use crate::exit::Verdict;
use crate::term::{Human, Json, Term, Traced, Trail};
use crate::text::{human, status_text};
use crate::values::Var;
use crate::{locate, values};

/// Runs `cli`, writing to `term` (each event kept in `trail` too); `var` reads the environment
/// (for secrets). Every command but `completions` runs on the runner `runner` builds; completions
/// need none, so they touch no home. An operation's failure has been reported by its own `error`
/// event, so it reads as [`Verdict::Fail`]; what failed before any event is an error.
pub(crate) async fn run<R: Borrow<Runner>>(cli: &Cli, runner: impl FnOnce() -> Result<R>, term: &Term, trail: &Trail, var: &Var<'_>) -> Result<Verdict> {
    let command = match &cli.command {
        Command::Completions { shell } => {
            term.out(&crate::args::completions(*shell));
            return Ok(Verdict::Pass);
        }
        Command::Runner(command) => command,
    };
    let runner = runner()?;
    let (json, human) = (Json(term), Human(term));
    let sink: &dyn Events = if cli.json { &json } else { &human };
    let traced = Traced { sink, trail };
    let out = Out {
        json: cli.json,
        term,
        events: &traced,
    };
    on_runner(command, runner.borrow(), &out, var).await
}

/// Runs `command` on `runner`, writing to `out`.
async fn on_runner(command: &RunnerCommand, runner: &Runner, out: &Out<'_>, var: &Var<'_>) -> Result<Verdict> {
    let Out { term, events, .. } = *out;
    match command {
        RunnerCommand::Status(run) => status(out, &open(runner, run)?).await,
        RunnerCommand::Check(op) => check(out, &open(runner, &op.run)?, &options(op, var)?).await,
        RunnerCommand::Provision { op, machine } => {
            let run = open(runner, &op.run)?;
            Ok(Verdict::of(run.provision(machine.as_deref(), &options(op, var)?, events).await.is_ok()))
        }
        RunnerCommand::Attach { op, machine, network, image } => {
            let mut pick = AccessPick::default();
            if let Some(m) = machine {
                pick = pick.with_machine(m);
            }
            if let Some(n) = network {
                pick = pick.with_network(n);
            }
            if let Some(i) = image {
                pick = pick.with_image(i);
            }
            attach(out, &open(runner, &op.run)?, pick, &options(op, var)?).await
        }
        RunnerCommand::Detach { op, machine } => {
            let run = open(runner, &op.run)?;
            Ok(Verdict::of(run.detach(machine.as_deref(), &options(op, var)?, events).await.is_ok()))
        }
        RunnerCommand::Console { run, machine, user } => print_command(out, &open(runner, run)?.console_command(machine, user.as_deref())?),
        RunnerCommand::Screen { run, machine, show_password } => screen(out, &open(runner, run)?, machine, *show_password).await,
        RunnerCommand::Events(run) => print_events(out, runner, &open(runner, run)?),
        RunnerCommand::Reap(args) => reap::run(args, runner, out, var).await,
        RunnerCommand::Key => {
            let key = runner.ssh_public_key().await?;
            if out.json {
                term.out(&serde_json::json!({ "public_key": key }).to_string());
            } else {
                term.out(&key);
            }
            Ok(Verdict::Pass)
        }
        RunnerCommand::Lifecycle(command) => lifecycle(command, runner, out, var).await,
    }
}

/// The operation `command` runs, when it runs one.
pub(crate) fn op_of(command: &Command) -> Option<Op> {
    let Command::Runner(command) = command else { return None };
    Some(match &**command {
        RunnerCommand::Lifecycle(Lifecycle::Up { .. }) => Op::Up,
        RunnerCommand::Lifecycle(Lifecycle::Down(_)) => Op::Down,
        RunnerCommand::Lifecycle(Lifecycle::Park { .. }) => Op::Park,
        RunnerCommand::Lifecycle(Lifecycle::Resume(_)) => Op::Resume,
        RunnerCommand::Lifecycle(Lifecycle::Update(_)) => Op::Update,
        RunnerCommand::Lifecycle(Lifecycle::Retire { .. }) => Op::Retire,
        RunnerCommand::Check(_) => Op::Check,
        RunnerCommand::Provision { .. } => Op::Provision,
        RunnerCommand::Attach { .. } => Op::Attach,
        RunnerCommand::Detach { .. } => Op::Detach,
        RunnerCommand::Status(_)
        | RunnerCommand::Console { .. }
        | RunnerCommand::Screen { .. }
        | RunnerCommand::Events(_)
        | RunnerCommand::Reap(_)
        | RunnerCommand::Key => {
            return None;
        }
    })
}

/// The run `command` names with `--name`, when it does.
pub(crate) fn run_named(command: &Command) -> Option<&str> {
    let Command::Runner(command) = command else { return None };
    let target = match &**command {
        RunnerCommand::Lifecycle(
            Lifecycle::Up { op, .. }
            | Lifecycle::Down(op)
            | Lifecycle::Park { op, .. }
            | Lifecycle::Resume(op)
            | Lifecycle::Update(op)
            | Lifecycle::Retire { op, .. },
        )
        | RunnerCommand::Check(op)
        | RunnerCommand::Provision { op, .. }
        | RunnerCommand::Attach { op, .. }
        | RunnerCommand::Detach { op, .. } => &op.run,
        RunnerCommand::Status(run) | RunnerCommand::Console { run, .. } | RunnerCommand::Screen { run, .. } | RunnerCommand::Events(run) => run,
        RunnerCommand::Reap(_) | RunnerCommand::Key => return None,
    };
    target.name.as_deref()
}

/// Where a command writes: the terminal (JSON lines with `json`), and its operation's events.
struct Out<'a> {
    json: bool,
    term: &'a Term,
    events: &'a dyn Events,
}

/// A command that changes the run's life, written to `out`.
async fn lifecycle(command: &Lifecycle, runner: &Runner, out: &Out<'_>, var: &Var<'_>) -> Result<Verdict> {
    let Out { json, term, events } = *out;
    match command {
        Lifecycle::Up {
            op,
            replace,
            lifetime,
            ssh_jump,
        } => {
            let started = open(runner, &op.run)?
                .up(&start_opts(op, *replace, *lifetime, ssh_jump.as_deref(), var)?, events)
                .await;
            Ok(said(json, started.ok(), "The run is up.", term))
        }
        Lifecycle::Down(op) => Ok(Verdict::of(open(runner, &op.run)?.down(&options(op, var)?, events).await.is_ok())),
        Lifecycle::Park { op, mode } => {
            let parked = open(runner, &op.run)?.park(park_mode(*mode), &options(op, var)?, events).await;
            Ok(Verdict::of(parked.is_ok()))
        }
        Lifecycle::Resume(op) => {
            let resumed = open(runner, &op.run)?.resume(&options(op, var)?, events).await;
            Ok(said(json, resumed.ok(), "The run is up again.", term))
        }
        Lifecycle::Update(op) => {
            let updated = open(runner, &op.run)?.update(&options(op, var)?, events).await;
            Ok(said(json, updated.ok(), "The run is up to date.", term))
        }
        Lifecycle::Retire { op, machine } => {
            let retired = open(runner, &op.run)?.retire(machine, &options(op, var)?, events).await;
            Ok(Verdict::of(retired.is_ok()))
        }
    }
}

/// How `mode` parks a run.
fn park_mode(mode: Mode) -> Park {
    match mode {
        Mode::Pause => Park::Pause,
        Mode::Shutdown => Park::Shutdown,
    }
}

/// A run that came up (a start, a resume, an update): its message, or `done`, unless the output is JSON
/// (its events said it); failed when it didn't come up.
fn said(json: bool, run: Option<isothrum_runner::Started>, done: &str, term: &Term) -> Verdict {
    let Some(run) = run else { return Verdict::Fail };
    if !json {
        term.out(run.message.as_deref().unwrap_or(done).trim_end());
    }
    Verdict::Pass
}

/// The run's status: JSON, or a short table.
async fn status(out: &Out<'_>, run: &Running<'_>) -> Result<Verdict> {
    let Out { json, term, .. } = *out;
    let status = run.status().await?;
    if json {
        term.out(&serde_json::to_string(&status)?);
    } else {
        for line in status_text(run.name().as_str(), &status) {
            term.out(&line);
        }
    }
    Ok(Verdict::Pass)
}

/// Hot-adds the on-demand access machine, then says where to reach it: in its `result` event's
/// data with `--json`, else as a line.
async fn attach(out: &Out<'_>, run: &Running<'_>, pick: AccessPick<'_>, opts: &RunOptions) -> Result<Verdict> {
    let Out { json, term, events } = *out;
    let Ok(report) = run.attach(pick, opts, events).await else {
        return Ok(Verdict::Fail);
    };
    if !json {
        let at = report.address.as_deref().map(|a| format!(" at {a}")).unwrap_or_default();
        term.out(&format!("Attached {}{at}.", report.machine));
    }
    Ok(Verdict::Pass)
}

/// The command that opens a console into a machine (`console`): one line to paste in a POSIX
/// shell ([`ShellCommand::sh_line`]: its folder, its variables, each word quoted as it needs), or
/// the `ShellCommand` JSON.
fn print_command(out: &Out<'_>, cmd: &ShellCommand) -> Result<Verdict> {
    let Out { json, term, .. } = *out;
    if json {
        term.out(&serde_json::to_string(cmd)?);
    } else {
        term.out(&cmd.sh_line());
    }
    Ok(Verdict::Pass)
}

/// `machine`'s graphical console, reached: where it answers (its password only when `show`), as
/// one JSON object or lines for a person; a tunnel it holds kept open until the command is
/// stopped.
async fn screen(out: &Out<'_>, run: &Running<'_>, machine: &str, show: bool) -> Result<Verdict> {
    let Out { json, term, .. } = *out;
    let console = run.graphical_console(machine).await?;
    let protocol = serde_json::to_value(console.protocol)?.as_str().unwrap_or_default().to_owned();
    let (address, websocket) = match &console.endpoint {
        Endpoint::Loopback(at) => (Some(at.to_string()), None),
        Endpoint::ProxmoxVnc(vnc) => (None, Some(vnc.websocket.expose().to_owned())),
        _ => (None, None),
    };
    let password = console.password.as_ref().filter(|_| show).map(|p| p.expose().to_owned());
    if json {
        let line = serde_json::json!({
            "machine": console.machine, "protocol": protocol, "address": address, "websocket": websocket,
            "user": console.user, "password": password, "tunnelled": console.tunnelled(), "note": console.note,
        });
        term.out(&line.to_string());
    } else {
        term.out(&format!("{protocol} {}", address.or(websocket).unwrap_or_default()));
        if let Some(user) = &console.user {
            term.out(&format!("user: {user}"));
        }
        match (&password, &console.password) {
            (Some(p), _) => term.out(&format!("password: {p}")),
            (None, Some(_)) => term.out("password: (hidden: --show-password prints it)"),
            (None, None) => {}
        }
        if console.tunnelled() {
            term.out("The tunnel stays open until this command is stopped (Ctrl-C).");
        }
    }
    if console.tunnelled() {
        std::future::pending::<()>().await;
    }
    Ok(Verdict::Pass)
}

/// The run's checks: a pass only when every one passed.
async fn check(out: &Out<'_>, run: &Running<'_>, opts: &RunOptions) -> Result<Verdict> {
    let Out { json, term, events } = *out;
    let Ok(report) = run.check(opts, events).await else {
        return Ok(Verdict::Fail);
    };
    if !json {
        let failed = report.results.iter().filter(|r| !r.ok).count();
        term.out(&format!("{} passed, {failed} failed", report.results.len() - failed));
    }
    Ok(Verdict::of(report.ok))
}

/// The run's recorded events, oldest first: JSON lines, or a line each for a person.
fn print_events(out: &Out<'_>, runner: &Runner, run: &Running<'_>) -> Result<Verdict> {
    let Out { json, term, .. } = *out;
    let log = runner.events(run.name())?;
    for event in log.events {
        if json {
            term.out(&serde_json::to_string(&event)?);
        } else if let Some(line) = human(&event.kind) {
            let op = event.op.map_or("", Op::as_str);
            term.out(&format!("{} {op:<6} {line}", event.time));
        }
    }
    if log.unreadable > 0 {
        term.err(&format!(
            "warning: {} line(s) of run `{}`'s event log couldn't be read (written by another version of Isothrum, or cut short)",
            log.unreadable,
            run.name()
        ));
    }
    Ok(Verdict::Pass)
}

/// The run `args` names.
fn open<'r>(runner: &'r Runner, args: &RunArgs) -> Result<Running<'r>> {
    let manifest = locate::manifest(&args.path, args.target.as_deref())?;
    // A malformed `--name` is a malformed value: a usage error, as clap's own are.
    let name = args
        .name
        .as_deref()
        .map(RunName::new)
        .transpose()
        .map_err(|e| Error::Usage(format!("--name: {e}")))?;
    Ok(runner.open(&manifest, name)?)
}

/// The start options: `args`, plus `replace` (remove leftovers first), a `lifetime` in minutes
/// and the SSH jump the run records.
fn start_opts(args: &OpArgs, replace: bool, lifetime: Option<u64>, ssh_jump: Option<&str>, var: &Var<'_>) -> Result<RunOptions> {
    let mut opts = options(args, var)?;
    if let Some(jump) = ssh_jump {
        opts = opts.with_ssh_jump(jump);
    }
    if replace {
        opts = opts.with_occupied(Occupied::Replace);
    }
    if let Some(minutes) = lifetime {
        opts = opts.with_lifetime(std::time::Duration::from_secs(minutes.saturating_mul(60)));
    }
    Ok(opts)
}

/// The options `args` gives.
fn options(args: &OpArgs, var: &Var<'_>) -> Result<RunOptions> {
    let mut opts = RunOptions::new()
        .with_params(values::params(&args.params)?)
        .with_secrets(values::secrets(&args.secrets, var)?);
    if let Some(label) = &args.host_label {
        opts = opts.with_host_label(label);
    }
    if let Some(provider) = args.provider {
        opts = opts.with_provider(provider);
    }
    Ok(opts)
}

mod reap;
#[cfg(test)]
mod tests;