use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use aion_server::config::{CliOverrides, ServerConfig, aion_home};
use aion_server::control::{IncarnationProbe, IncarnationState, incarnation, outcome, pid_file};
use clap::Args;
use serde::Deserialize;
use crate::console::PROBE_STEP_TIMEOUT;
#[derive(Args, Clone, Debug)]
pub struct StatusArgs {
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct BuildBody {
version: String,
commit: String,
dirty: String,
built_at: String,
}
pub async fn run(args: &StatusArgs) -> ExitCode {
let home = match aion_home() {
Ok(home) => home.path,
Err(error) => {
eprintln!("aion server status: could not resolve the Aion home: {error}");
return ExitCode::from(2);
}
};
let overrides = CliOverrides {
config_path: args.config.clone(),
..CliOverrides::default()
};
let config = match ServerConfig::load(&overrides) {
Ok(config) => Some(config),
Err(error) => {
eprintln!(
"configuration: could not load ({error}) — the store line and the \
no-record probe address are unavailable; the record and process \
layers below still answer"
);
None
}
};
println!("home: {}", home.display());
render_store_layer(config.as_ref());
let mut verb_failed = false;
let record = match pid_file::read(&home) {
Ok(record) => record,
Err(error) => {
eprintln!("pid file: {error}");
verb_failed = true;
None
}
};
let mut running_verified = false;
let http_address = if let Some(record) = &record {
running_verified = render_record_layer(&home, record);
if let Some(address) = record.http_address {
println!("probing the address the record says the server bound: {address}");
Some(address)
} else {
println!(
"the doors are not open yet: this incarnation has bound no listener, \
so there is nothing to probe. That is the expected state while a \
server boots, not a fault"
);
None
}
} else {
if !verb_failed {
println!(
"pid file: none at {} — no server has claimed this home (or the \
running server predates the pid file)",
pid_file::pid_file_path(&home).display()
);
}
if let Some(config) = &config {
let address = config.server.listen_address;
println!("probing the CONFIGURED address (no recorded bind): {address}");
Some(address)
} else {
println!(
"no recorded bind and no readable configuration — the listener \
layers cannot be probed"
);
None
}
};
let mut identity_confirmed = false;
if let Some(http_address) = http_address {
identity_confirmed = render_build_layer(http_address).await;
render_ready_layer(http_address, identity_confirmed).await;
}
if let Some(http_address) = http_address
&& identity_confirmed
&& !running_verified
{
println!(
"note: an aion server IS answering on {http_address}, but it is not a \
verified recorded incarnation of this home — a server booted unclaimed \
(another claimant held the record), or one predating the pid file. The \
control verbs address only the recorded server"
);
}
if verb_failed {
ExitCode::from(2)
} else if running_verified {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_store_layer(config: Option<&ServerConfig>) {
match config.map(|config| &config.store) {
Some(store) => match store.backend {
aion_server::config::StoreBackend::Haematite => match &store.data_dir {
Some(data_dir) => println!("store: {data_dir}"),
None => println!("store: haematite (data directory resolved by the server)"),
},
aion_server::config::StoreBackend::Memory => {
println!("store: in-memory — state does not survive a stop");
}
},
None => println!("store: unknown — the configuration could not be read"),
}
}
fn render_record_layer(home: &std::path::Path, record: &pid_file::PidRecord) -> bool {
println!(
"pid file: pid {} — version {}, commit {}, {}",
record.pid,
record.version,
record.commit,
bound_addresses_line(record)
);
match incarnation::probe(record) {
IncarnationProbe::Verified { .. } => {
let uptime_secs = record.running_for_secs();
println!("process: RUNNING, incarnation verified (started {uptime_secs}s ago)");
println!("state: {}", state_line(record));
return record.state == IncarnationState::Serving;
}
IncarnationProbe::ProcessGone => {
println!(
"process: GONE — the recorded pid is not in the process table; the \
server died without removing its record"
);
match outcome::read_fate(home, record.pid) {
Ok(fate) => crate::server_stop::render_fate(record.pid, &fate),
Err(error) => println!("death note: {error}"),
}
}
IncarnationProbe::DifferentIncarnation {
live_started_at_unix_secs,
exe,
} => {
let exe_note = exe.map_or_else(String::new, |path| {
format!(" (running `{}`)", path.display())
});
println!(
"process: MISMATCH — pid {} is alive but started at unix second {}, \
not the recorded {}{exe_note}; the pid was recycled onto a different \
process after the recorded server died",
record.pid, live_started_at_unix_secs, record.started_at_unix_secs
);
}
}
false
}
fn bound_addresses_line(record: &pid_file::PidRecord) -> String {
match (record.http_address, record.grpc_address) {
(Some(http), Some(grpc)) => format!("bound http {http} / grpc {grpc}"),
(Some(http), None) => format!("bound http {http} / grpc not yet bound"),
(None, Some(grpc)) => format!("http not yet bound / bound grpc {grpc}"),
(None, None) => "no listener bound yet".to_owned(),
}
}
fn state_line(record: &pid_file::PidRecord) -> String {
let label = record.state.label();
match record.state {
IncarnationState::Serving => label.to_owned(),
IncarnationState::Booting | IncarnationState::Draining => {
let reached = record.stage_line().map_or_else(
|| " (no stage reported yet".to_owned(),
|line| format!(" (stage: {line}"),
);
let progress = record
.stage_age_secs()
.map_or_else(String::new, |age| format!(", last progress {age}s ago"));
format!(
"{label}{reached}, started {}s ago{progress})",
record.running_for_secs()
)
}
}
}
async fn render_build_layer(address: SocketAddr) -> bool {
let url = format!("http://{address}/build");
match probe(&url).await {
Err(error) => {
println!("build: unreachable — {error}");
false
}
Ok((status, body)) => {
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
println!(
"build: the endpoint refused this unauthenticated probe ({status}); \
the listener is up but auth is enabled"
);
return false;
}
if let Ok(build) = serde_json::from_str::<BuildBody>(&body) {
let dirty = if build.dirty == "true" { "-dirty" } else { "" };
println!(
"build: {}+{}{} built {}",
build.version, build.commit, dirty, build.built_at
);
true
} else {
println!(
"build: the listener answered {status} but the body is not a build \
identity — an app shell or a different service is on this address \
(an image without /build answers 200 here; the body is the truth)"
);
false
}
}
}
}
async fn render_ready_layer(address: SocketAddr, identity_confirmed: bool) {
let url = format!("http://{address}/health/ready");
match probe(&url).await {
Err(error) => println!("ready: unreachable — {error}"),
Ok((status, body)) => {
if status == reqwest::StatusCode::OK && body.is_empty() {
println!("ready: YES — the store answered the readiness probe");
} else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
println!(
"ready: NO — the server is up but its runtime or store did not \
answer the readiness probe"
);
} else if status == reqwest::StatusCode::OK && identity_confirmed {
println!(
"ready: this server does not mount /health/ready (the SPA shell \
answered for the route); the address IS this aion server — its \
/build just answered with a valid identity — but no health state \
was commissioned"
);
} else if status == reqwest::StatusCode::OK {
println!(
"ready: the listener answered 200 with a body, but /health/ready \
answers with an empty one — an app shell or a different service \
is on this address"
);
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
println!(
"ready: the endpoint refused this unauthenticated probe ({status}); \
the listener is up but auth is enabled, so readiness cannot be \
probed unauthenticated"
);
} else {
println!("ready: unexpected answer {status}");
}
}
}
}
async fn probe(url: &str) -> Result<(reqwest::StatusCode, String), String> {
let client = reqwest::Client::builder()
.timeout(PROBE_STEP_TIMEOUT)
.build()
.map_err(|error| error.to_string())?;
let response = client
.get(url)
.send()
.await
.map_err(|error| error.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|error| error.to_string())?;
Ok((status, body))
}
#[cfg(test)]
mod tests {
use super::{bound_addresses_line, state_line};
use aion_server::control::{IncarnationState, PidRecord};
fn record(state: IncarnationState) -> PidRecord {
PidRecord {
pid: 8941,
started_at_unix_secs: aion_server::control::pid_file::now_unix_secs()
.saturating_sub(93),
binary_sha256: "0".repeat(64),
version: "0.25.1".to_owned(),
commit: "test".to_owned(),
state,
http_address: None,
grpc_address: None,
intended_http_address: None,
intended_grpc_address: None,
stage: Some("wal-recovery".to_owned()),
stage_detail: Some("materializing shard 17 of 64".to_owned()),
stage_seq: 17,
stage_updated_at_unix_secs: aion_server::control::pid_file::now_unix_secs()
.saturating_sub(2),
drain_timeout_seconds: 0,
}
}
#[test]
fn a_booting_record_renders_its_stage_and_its_last_progress() {
let line = state_line(&record(IncarnationState::Booting));
assert!(line.starts_with("BOOTING"), "{line}");
assert!(line.contains("wal-recovery"), "{line}");
assert!(line.contains("materializing shard 17 of 64"), "{line}");
assert!(line.contains("started 93s ago"), "{line}");
assert!(line.contains("last progress"), "{line}");
}
#[test]
fn a_draining_record_is_rendered_as_mid_something() {
let line = state_line(&record(IncarnationState::Draining));
assert!(line.starts_with("DRAINING"), "{line}");
assert!(line.contains("started 93s ago"), "{line}");
}
#[test]
fn a_serving_record_renders_as_one_word() {
assert_eq!(state_line(&record(IncarnationState::Serving)), "SERVING");
}
#[test]
fn an_unbound_record_names_the_absence_rather_than_inventing_an_address()
-> Result<(), std::net::AddrParseError> {
let mut unbound = record(IncarnationState::Booting);
assert_eq!(bound_addresses_line(&unbound), "no listener bound yet");
unbound.http_address = Some("127.0.0.1:8080".parse()?);
assert_eq!(
bound_addresses_line(&unbound),
"bound http 127.0.0.1:8080 / grpc not yet bound"
);
unbound.grpc_address = Some("127.0.0.1:50051".parse()?);
assert_eq!(
bound_addresses_line(&unbound),
"bound http 127.0.0.1:8080 / grpc 127.0.0.1:50051"
);
Ok(())
}
}