use std::path::{Path, PathBuf};
use std::process::ExitCode;
use aion_server::config::aion_home;
use clap::Args;
use crate::boot_narration::BootObservation;
use crate::handover::{self, HandoverRefusal};
#[derive(Args, Clone, Debug)]
pub struct UpdateArgs {
#[arg(long)]
version: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
patience: Option<std::num::NonZeroU64>,
}
pub async fn run(args: &UpdateArgs) -> ExitCode {
match update(args).await {
Ok(code) => code,
Err(refusal) => {
eprintln!("aion update: {}", refusal.message);
ExitCode::from(refusal.code)
}
}
}
async fn update(args: &UpdateArgs) -> Result<ExitCode, HandoverRefusal> {
let home = aion_home()
.map_err(|error| {
HandoverRefusal::refused(format!("could not resolve the Aion home: {error}"))
})?
.path;
let target = match &args.version {
Some(version) => version.clone(),
None => crate::update_index::latest_stable()
.await
.map_err(|error| HandoverRefusal::refused(error.to_string()))?,
};
let current = env!("CARGO_PKG_VERSION");
let running = running_server(&home);
if let Some(reason) = already_current(current, &target, running.as_ref()) {
println!("{reason}");
return Ok(ExitCode::SUCCESS);
}
println!("installing aion-cli {target} (this binary is {current})");
let log_path = install_log_path(&home, &target)?;
install(&target, &log_path)?;
let executable = installed_binary(&target, &log_path)?;
println!("installed aion {target} at {}", executable.display());
let Some(predecessor) = running else {
println!(
"no server is running on this home; aion {target} serves it from the next \
`aion` launch"
);
return Ok(ExitCode::SUCCESS);
};
let patience = crate::server_restart::resolve_patience(
args.patience,
args.config.as_deref(),
&predecessor,
)?;
handover::release_predecessor(&home, &predecessor, patience)?;
let successor = handover::start_successor(&home, &executable, Some(&predecessor)).await?;
println!("{}", update_summary(&target, &executable, &successor));
Ok(ExitCode::SUCCESS)
}
fn update_summary(
target: &str,
executable: &Path,
successor: &aion_server::control::PidRecord,
) -> String {
let served = if successor.version == target {
String::new()
} else {
format!(
" (the running server reports {}, the aion-server version this aion-cli \
resolved)",
successor.version
)
};
format!(
"updated: aion-cli {target} installed at {}; this home is served by pid {}{served}",
executable.display(),
successor.pid
)
}
fn running_server(home: &Path) -> Option<aion_server::control::PidRecord> {
match crate::boot_narration::read_observation(home) {
BootObservation::Live(record) => Some(*record),
BootObservation::NoRecord | BootObservation::NotLive(_) => None,
BootObservation::Unreadable(error) => {
eprintln!(
"aion update: this home's pid record could not be read ({error}); the \
install will run, but no server will be handed over — check `aion \
server status` afterwards"
);
None
}
}
}
fn already_current(
current: &str,
target: &str,
running: Option<&aion_server::control::PidRecord>,
) -> Option<String> {
if current != target {
return None;
}
match running {
None => Some(format!(
"already current: aion {current} is installed and no server is running on \
this home"
)),
Some(record) if record.version == target => Some(format!(
"already current: aion {current} is installed and pid {} is serving this \
home on that version",
record.pid
)),
Some(record) => {
println!(
"this binary is already aion {current}, but pid {} is serving this home \
on {} — handing the home over to {current}",
record.pid, record.version
);
None
}
}
}
fn install_log_path(home: &Path, target: &str) -> Result<PathBuf, HandoverRefusal> {
let logs = home.join("logs");
std::fs::create_dir_all(&logs).map_err(|error| {
HandoverRefusal::refused(format!(
"could not create the log directory {}: {error}; nothing was installed",
logs.display()
))
})?;
Ok(logs.join(format!("update-{target}.log")))
}
fn install(target: &str, log_path: &Path) -> Result<(), HandoverRefusal> {
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.map_err(|error| {
HandoverRefusal::refused(format!(
"could not open the install log {}: {error}; nothing was installed",
log_path.display()
))
})?;
let errors = log.try_clone().map_err(|error| {
HandoverRefusal::refused(format!(
"could not open the install log {}: {error}; nothing was installed",
log_path.display()
))
})?;
println!("install log: {}", log_path.display());
let status = std::process::Command::new(cargo_binary())
.args(["install", "aion-cli", "--version", target])
.stdin(std::process::Stdio::null())
.stdout(log)
.stderr(errors)
.status()
.map_err(|error| {
HandoverRefusal::refused(format!(
"could not run `cargo install`: {error}. Nothing was installed and \
nothing running was touched; install cargo, or put it on PATH"
))
})?;
if !status.success() {
return Err(HandoverRefusal::incomplete(format!(
"`cargo install aion-cli --version {target}` failed ({status}). NOTHING \
RUNNING WAS TOUCHED — the server on this home is the one that was serving \
before. The install's own output is in {}",
log_path.display()
)));
}
Ok(())
}
fn cargo_binary() -> PathBuf {
std::env::var_os("CARGO").map_or_else(|| PathBuf::from("cargo"), PathBuf::from)
}
fn installed_binary(target: &str, log_path: &Path) -> Result<PathBuf, HandoverRefusal> {
let root = install_root().ok_or_else(|| {
HandoverRefusal::incomplete(format!(
"aion-cli {target} was installed, but this verb cannot tell WHERE: neither \
CARGO_INSTALL_ROOT, CARGO_HOME, nor HOME is set, so cargo's install root \
cannot be resolved. Nothing running was touched. The install's output is \
in {}; restart the server yourself once you have confirmed the path",
log_path.display()
))
})?;
let executable = root.join("bin").join("aion");
if !executable.is_file() {
return Err(HandoverRefusal::incomplete(format!(
"aion-cli {target} reported a successful install, but no `aion` binary is \
at {}. Nothing running was touched. The install's output is in {}",
executable.display(),
log_path.display()
)));
}
let reported = binary_version(&executable).map_err(|error| {
HandoverRefusal::incomplete(format!(
"the freshly installed binary at {} could not be asked its version \
({error}); nothing running was touched",
executable.display()
))
})?;
if reported != target {
return Err(HandoverRefusal::incomplete(format!(
"the binary at {} reports version {reported}, not the {target} that was \
just installed — the install wrote somewhere this verb did not look. \
NOTHING RUNNING WAS TOUCHED; find the installed binary and restart the \
server with it. The install's output is in {}",
executable.display(),
log_path.display()
)));
}
Ok(executable)
}
fn install_root() -> Option<PathBuf> {
if let Some(root) = std::env::var_os("CARGO_INSTALL_ROOT") {
return Some(PathBuf::from(root));
}
if let Some(home) = std::env::var_os("CARGO_HOME") {
return Some(PathBuf::from(home));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo"))
}
fn binary_version(executable: &Path) -> Result<String, String> {
let output = std::process::Command::new(executable)
.arg("--version")
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(format!("`--version` exited {}", output.status));
}
let line = String::from_utf8_lossy(&output.stdout);
parse_version_line(&line).ok_or_else(|| format!("`--version` printed `{}`", line.trim()))
}
fn parse_version_line(line: &str) -> Option<String> {
line.split_whitespace().nth(1).map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::{already_current, install_log_path, parse_version_line, update_summary};
use aion_server::control::{IncarnationState, PidRecord};
fn serving(version: &str) -> PidRecord {
PidRecord {
pid: 4242,
started_at_unix_secs: 1,
binary_sha256: "0".repeat(64),
version: version.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: 30,
}
}
#[test]
fn already_current_needs_the_binary_and_the_server_to_agree()
-> Result<(), Box<dyn std::error::Error>> {
let idle = already_current("0.25.1", "0.25.1", None)
.ok_or("an idle home on the target version must read as current")?;
assert!(idle.contains("no server is running"), "{idle}");
let serving_target = already_current("0.25.1", "0.25.1", Some(&serving("0.25.1")))
.ok_or("a server on the target version must read as current")?;
assert!(serving_target.contains("4242"), "{serving_target}");
Ok(())
}
#[test]
fn a_stale_server_under_a_current_binary_is_not_already_current() {
assert!(
already_current("0.26.0", "0.26.0", Some(&serving("0.25.1"))).is_none(),
"a server on an older version must not read as already current"
);
}
#[test]
fn a_different_target_is_never_already_current() {
assert!(already_current("0.25.1", "0.26.0", None).is_none());
assert!(already_current("0.26.0", "0.25.1", Some(&serving("0.26.0"))).is_none());
}
#[test]
fn the_install_log_is_named_for_its_target() -> Result<(), Box<dyn std::error::Error>> {
let home = tempfile::tempdir()?;
let path =
install_log_path(home.path(), "0.26.0").map_err(|refusal| refusal.message.clone())?;
assert!(path.ends_with("logs/update-0.26.0.log"), "{path:?}");
assert!(path.parent().is_some_and(std::path::Path::is_dir));
Ok(())
}
#[test]
fn the_summary_names_the_installed_target_and_flags_a_differing_server() {
let mut record = serving("0.25.1");
record.pid = 22103;
let line = update_summary("0.25.0", std::path::Path::new("/root/bin/aion"), &record);
assert!(line.contains("aion-cli 0.25.0"), "{line}");
assert!(line.contains("/root/bin/aion"), "{line}");
assert!(line.contains("22103"), "{line}");
assert!(
line.contains("reports 0.25.1"),
"a server version differing from the target must be named: {line}"
);
let agreeing = update_summary(
"0.25.1",
std::path::Path::new("/root/bin/aion"),
&serving("0.25.1"),
);
assert!(!agreeing.contains("reports"), "{agreeing}");
}
#[test]
fn the_version_line_is_read_or_refused() {
assert_eq!(
parse_version_line("aion 0.26.0\n").as_deref(),
Some("0.26.0")
);
assert_eq!(
parse_version_line("aion-cli 1.2.3").as_deref(),
Some("1.2.3")
);
assert_eq!(parse_version_line("aion"), None);
assert_eq!(parse_version_line(""), None);
}
}