isothrum-cli 0.11.0

isothrum: run what Isoloom generates (up, down, park, resume, status, check, events) from the command line.
//! Each command, on a runner the caller built.

use isothrum_runner::{Events, Lab, Occupied, Park, RunName, RunOptions, Runner};

use crate::args::{Cli, Command, LabArgs, Mode, OpArgs};
use crate::error::Result;
use crate::exit::Verdict;
use crate::term::{Human, Json, Term};
use crate::text::{human, status_text};
use crate::{locate, values};

/// Runs `cli` on `runner`, writing to `term`; `var` reads the environment (for secrets). 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(cli: &Cli, runner: &Runner, term: &Term, var: &dyn Fn(&str) -> Option<String>) -> Result<Verdict> {
    let (json, human) = (Json(term), Human(term));
    let events: &dyn Events = if cli.json { &json } else { &human };
    match &cli.command {
        Command::Up { op, replace, lifetime } => {
            let lab = open(runner, &op.lab)?;
            let mut opts = options(op, var)?;
            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)));
            }
            let Ok(run) = lab.up(&opts, events).await else { return Ok(Verdict::Fail) };
            if !cli.json {
                term.out(run.message.as_deref().unwrap_or("The lab is up.").trim_end());
            }
            Ok(Verdict::Pass)
        }
        Command::Down(op) => Ok(Verdict::of(open(runner, &op.lab)?.down(&options(op, var)?, events).await.is_ok())),
        Command::Park { lab, mode } => {
            let mode = match mode {
                Mode::Pause => Park::Pause,
                Mode::Shutdown => Park::Shutdown,
            };
            Ok(Verdict::of(open(runner, lab)?.park(mode, events).await.is_ok()))
        }
        Command::Resume(op) => {
            let Ok(run) = open(runner, &op.lab)?.resume(&options(op, var)?, events).await else {
                return Ok(Verdict::Fail);
            };
            if !cli.json {
                term.out(run.message.as_deref().unwrap_or("The lab is up again.").trim_end());
            }
            Ok(Verdict::Pass)
        }
        Command::Status(lab) => status(cli.json, &open(runner, lab)?, term).await,
        Command::Check(op) => check(cli.json, &open(runner, &op.lab)?, &options(op, var)?, events, term).await,
        Command::Provision { op, machine } => {
            let lab = open(runner, &op.lab)?;
            Ok(Verdict::of(lab.provision(machine.as_deref(), &options(op, var)?, events).await.is_ok()))
        }
        Command::Events(lab) => print_events(cli.json, runner, &open(runner, lab)?, term),
        Command::Reap(args) => reap::run(args, runner, cli.json, term, var).await,
        Command::Key => {
            term.out(&runner.ssh_public_key().await?);
            Ok(Verdict::Pass)
        }
    }
}

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

/// The lab's checks: a pass only when every one passed.
async fn check(json: bool, lab: &Lab<'_>, opts: &RunOptions, events: &dyn Events, term: &Term) -> Result<Verdict> {
    let Ok(report) = lab.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(json: bool, runner: &Runner, lab: &Lab<'_>, term: &Term) -> Result<Verdict> {
    for event in runner.events(lab.name())? {
        if json {
            term.out(&serde_json::to_string(&event)?);
        } else if let Some(line) = human(&event.kind) {
            term.out(&format!("{} {:<6} {line}", event.time, event.op.as_str()));
        }
    }
    Ok(Verdict::Pass)
}

/// The run `args` names.
fn open<'r>(runner: &'r Runner, args: &LabArgs) -> Result<Lab<'r>> {
    let manifest = locate::manifest(&args.lab, args.target.as_deref())?;
    let name = args.name.as_deref().map(RunName::new).transpose()?;
    Ok(runner.open(&manifest, name)?)
}

/// The options `args` gives.
fn options(args: &OpArgs, var: &dyn Fn(&str) -> Option<String>) -> 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);
    }
    if let Some(jump) = &args.ssh_jump {
        opts = opts.with_ssh_jump(jump);
    }
    Ok(opts)
}

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