aion-cli 0.31.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
//! `aion server restart`: hand this home over from the running server to a
//! fresh one of the SAME binary, and narrate both halves until it serves.
//!
//! The handover itself lives in [`crate::handover`], because `aion update`
//! performs exactly the same one with a newly installed binary. What is here
//! is this verb's own surface: which executable starts (this one), and how the
//! drain-side patience is resolved.
//!
//! Exit codes: 0 when the successor is serving; 1 when the handover was
//! attempted and did not complete (the successor died, or the predecessor
//! would not let go); 2 for a refusal before anything was signalled.

use std::num::NonZeroU64;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;

use aion_server::config::aion_home;
use aion_server::control::PidRecord;
use clap::Args;

use crate::handover::{self, HandoverRefusal};

/// Arguments for `aion server restart`.
#[derive(Args, Clone, Debug)]
pub struct RestartArgs {
    /// Path to the TOML server configuration file. Used only to resolve the
    /// drain patience when the running server's record carries none.
    #[arg(long)]
    pub config: Option<PathBuf>,
    /// Seconds to wait for the running server to begin draining before giving
    /// up on the handover. Absent, the running server's own recorded drain
    /// window governs, then the config's `drain.timeout_seconds` — the verb
    /// never invents a value.
    #[arg(long)]
    pub patience: Option<NonZeroU64>,
}

/// Run `aion server restart`.
pub async fn run(args: &RestartArgs) -> ExitCode {
    match restart(args).await {
        Ok(code) => code,
        Err(refusal) => {
            eprintln!("aion server restart: {}", refusal.message);
            ExitCode::from(refusal.code)
        }
    }
}

async fn restart(args: &RestartArgs) -> Result<ExitCode, HandoverRefusal> {
    let home = aion_home()
        .map_err(|error| {
            HandoverRefusal::refused(format!("could not resolve the Aion home: {error}"))
        })?
        .path;
    let predecessor = handover::resolve_predecessor(&home, "restart")?;
    let patience = resolve_patience(args.patience, args.config.as_deref(), &predecessor)?;
    handover::release_predecessor(&home, &predecessor, patience)?;
    let executable = std::env::current_exe().map_err(|error| {
        HandoverRefusal::incomplete(format!(
            "the previous server was signalled but this binary's own path could not be \
             resolved to start its successor: {error}"
        ))
    })?;
    let _successor = handover::start_successor(&home, &executable, Some(&predecessor)).await?;
    Ok(ExitCode::SUCCESS)
}

/// The drain-side patience: the flag, then the running server's own recorded
/// window, then the config's `drain.timeout_seconds`. Never invented — the
/// same precedence `aion server stop` uses, and for the same reason: the
/// window the server is ACTUALLY running with may have come from its own
/// command line, and two verbs waiting out different windows on one server
/// would report different things about the same drain.
///
/// # Errors
///
/// Returns [`HandoverRefusal::refused`] when no window can be resolved from
/// any of the three sources — nothing is signalled without one to report on.
pub fn resolve_patience(
    flag: Option<NonZeroU64>,
    config_path: Option<&std::path::Path>,
    predecessor: &PidRecord,
) -> Result<Duration, HandoverRefusal> {
    if let Some(seconds) = flag {
        return Ok(Duration::from_secs(seconds.get()));
    }
    if predecessor.drain_timeout_seconds > 0 {
        return Ok(Duration::from_secs(predecessor.drain_timeout_seconds));
    }
    let overrides = aion_server::config::CliOverrides {
        config_path: config_path.map(std::path::Path::to_path_buf),
        ..aion_server::config::CliOverrides::default()
    };
    match aion_server::config::ServerConfig::load(&overrides) {
        Ok(config) => Ok(Duration::from_secs(config.drain.timeout_seconds)),
        Err(error) => Err(HandoverRefusal::refused(format!(
            "the running server records no drain window and the configuration could \
             not be read to supply one ({error}); nothing was signalled. Pass \
             `--patience <seconds>` to rule it at invocation"
        ))),
    }
}

#[cfg(test)]
mod tests {
    use super::resolve_patience;
    use aion_server::control::{IncarnationState, PidRecord};
    use std::num::NonZeroU64;
    use std::time::Duration;

    fn serving(drain_timeout_seconds: u64) -> PidRecord {
        PidRecord {
            pid: 1234,
            started_at_unix_secs: 1,
            binary_sha256: "0".repeat(64),
            version: "0.0.0-test".to_owned(),
            commit: "test".to_owned(),
            state: IncarnationState::Serving,
            http_address: None,
            grpc_address: None,
            intended_http_address: None,
            intended_grpc_address: None,
            stage: None,
            stage_detail: None,
            stage_seq: 0,
            stage_updated_at_unix_secs: 0,
            drain_timeout_seconds,
        }
    }

    /// The flag outranks the record's window, and the record's window
    /// outranks the config — the same precedence `stop` uses, so the two
    /// verbs cannot wait out different windows on the same server.
    #[test]
    fn the_drain_patience_prefers_the_flag_then_the_record()
    -> Result<(), Box<dyn std::error::Error>> {
        let record = serving(300);
        let patience = resolve_patience(NonZeroU64::new(7), None, &record)
            .map_err(|refusal| refusal.message.clone())?;
        assert_eq!(patience, Duration::from_secs(7));

        let patience =
            resolve_patience(None, None, &record).map_err(|refusal| refusal.message.clone())?;
        assert_eq!(
            patience,
            Duration::from_secs(300),
            "the running server's own recorded window must govern"
        );
        Ok(())
    }

    /// A BOOTING record carries no window (it has not resolved one yet), and
    /// a record that predates the field reads as 0. Both honestly mean "no
    /// recorded window" and must fall through rather than resolve to zero,
    /// which would give up on the drain instantly.
    #[test]
    fn a_zero_window_falls_through_rather_than_governing() {
        let record = serving(0);
        // With no config file discoverable in the test environment this may
        // resolve from an ambient config or refuse; either is correct. What
        // must NOT happen is a zero-second patience.
        match resolve_patience(None, None, &record) {
            Ok(patience) => assert_ne!(
                patience,
                Duration::ZERO,
                "a zero recorded window must never become a zero patience"
            ),
            Err(refusal) => assert_eq!(refusal.code, 2),
        }
    }
}