use std::fmt;
use std::net::SocketAddr;
use std::path::Path;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Pass,
Warn,
Fail,
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Pass => "ok ",
Self::Warn => "warn",
Self::Fail => "FAIL",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
impl Check {
pub fn pass(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(name, Status::Pass, detail)
}
pub fn warn(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(name, Status::Warn, detail)
}
pub fn fail(name: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(name, Status::Fail, detail)
}
fn new(name: impl Into<String>, status: Status, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
status,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct Report {
server: String,
version: String,
checks: Vec<Check>,
}
impl Report {
pub fn new(server: impl Into<String>, version: impl Into<String>) -> Self {
Self {
server: server.into(),
version: version.into(),
checks: Vec::new(),
}
}
pub fn push(&mut self, check: Check) -> &mut Self {
self.checks.push(check);
self
}
pub fn checks(&self) -> &[Check] {
&self.checks
}
pub fn is_ok(&self) -> bool {
self.checks.iter().all(|c| c.status != Status::Fail)
}
pub fn exit_code(&self) -> i32 {
if self.is_ok() { 0 } else { 1 }
}
pub fn print(&self) {
print!("{self}");
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{} {} doctor", self.server, self.version)?;
let width = self.checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
for c in &self.checks {
writeln!(f, " [{}] {:<width$} {}", c.status, c.name, c.detail)?;
}
let failed = self
.checks
.iter()
.filter(|c| c.status == Status::Fail)
.count();
let warned = self
.checks
.iter()
.filter(|c| c.status == Status::Warn)
.count();
if failed == 0 {
writeln!(f, "all good ({warned} warning(s))")
} else {
writeln!(f, "{failed} check(s) failed, {warned} warning(s)")
}
}
}
pub fn port_free(addr: SocketAddr) -> Check {
match std::net::TcpListener::bind(addr) {
Ok(_) => Check::pass("listen address", format!("{addr} is free")),
Err(e) => Check::fail(
"listen address",
format!("{addr} cannot be bound ({e}); is the server already running?"),
),
}
}
pub fn env_set(var: &str, secret: bool) -> Check {
match std::env::var(var) {
Ok(v) if !v.trim().is_empty() => {
let shown = if secret {
format!("set ({} chars)", v.chars().count())
} else {
format!("= {v}")
};
Check::pass(var, shown)
}
Ok(_) => Check::fail(var, "set but empty"),
Err(_) => Check::fail(var, "not set"),
}
}
pub async fn tcp_reachable(name: &str, host: &str, port: u16, timeout: Duration) -> Check {
let target = format!("{host}:{port}");
match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&target)).await {
Ok(Ok(_)) => Check::pass(name, format!("{target} reachable")),
Ok(Err(e)) => Check::fail(name, format!("{target}: {e}")),
Err(_) => Check::fail(name, format!("{target}: no answer within {timeout:?}")),
}
}
pub fn dir_readable(path: impl AsRef<Path>) -> Check {
let path = path.as_ref();
let name = format!("dir {}", path.display());
if !path.is_dir() {
return Check::fail(name, "not a directory or does not exist");
}
match std::fs::read_dir(path) {
Ok(entries) => Check::pass(name, format!("readable ({} entries)", entries.count())),
Err(e) => Check::fail(name, format!("cannot be listed: {e}")),
}
}
pub fn dir_writable(path: impl AsRef<Path>) -> Check {
let path = path.as_ref();
let name = format!("dir {}", path.display());
if !path.is_dir() {
return Check::fail(name, "not a directory or does not exist");
}
let probe = path.join(format!(".emperor-doctor-{}", uuid::Uuid::new_v4().simple()));
let created = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe);
match created {
Ok(file) => {
drop(file);
match std::fs::remove_file(&probe) {
Ok(()) => Check::pass(name, "writable"),
Err(e) => Check::warn(
name,
format!(
"writable, but the probe {} was not removed: {e}",
probe.display()
),
),
}
}
Err(e) => Check::fail(name, format!("cannot create files: {e}")),
}
}
pub const MAX_TOOLS: usize = 8;
pub fn tool_budget(count: usize) -> Check {
if count == 0 {
Check::fail("tool budget", "no tools registered")
} else if count > MAX_TOOLS {
Check::fail(
"tool budget",
format!("{count} tools, the limit is {MAX_TOOLS}; split the server"),
)
} else {
Check::pass(
"tool budget",
format!("{count} of at most {MAX_TOOLS} tools"),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_fails_only_on_fail() {
let mut r = Report::new("t", "0.0.0");
r.push(Check::pass("a", "fine"))
.push(Check::warn("b", "hmm"));
assert!(r.is_ok());
assert_eq!(r.exit_code(), 0);
r.push(Check::fail("c", "broken"));
assert!(!r.is_ok());
assert_eq!(r.exit_code(), 1);
let text = r.to_string();
assert!(text.contains("[FAIL] c"), "{text}");
assert!(text.contains("1 check(s) failed, 1 warning(s)"), "{text}");
}
#[test]
fn secret_env_is_never_printed() {
unsafe { std::env::set_var("EMPEROR_DOCTOR_TEST_SECRET", "hunter2") };
let c = env_set("EMPEROR_DOCTOR_TEST_SECRET", true);
assert_eq!(c.status, Status::Pass);
assert!(!c.detail.contains("hunter2"));
assert_eq!(c.detail, "set (7 chars)");
assert_eq!(
env_set("EMPEROR_DOCTOR_TEST_UNSET", false).status,
Status::Fail
);
}
#[test]
fn tool_budget_bounds() {
assert_eq!(tool_budget(0).status, Status::Fail);
assert_eq!(tool_budget(6).status, Status::Pass);
assert_eq!(tool_budget(MAX_TOOLS + 1).status, Status::Fail);
}
#[test]
fn busy_port_fails() {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
assert_eq!(port_free(addr).status, Status::Fail);
}
#[tokio::test]
async fn reachable_and_unreachable() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
let ok = tcp_reachable("svc", "127.0.0.1", port, Duration::from_secs(2)).await;
assert_eq!(ok.status, Status::Pass);
drop(l);
let gone = tcp_reachable("svc", "127.0.0.1", port, Duration::from_secs(2)).await;
assert_eq!(gone.status, Status::Fail);
}
#[test]
fn dir_checks() {
let dir = std::env::temp_dir().join(format!("emperor-doctor-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "x").unwrap();
let r = dir_readable(&dir);
assert_eq!(r.status, Status::Pass, "{r:?}");
assert!(r.detail.contains("1 entries"), "{r:?}");
let w = dir_writable(&dir);
assert_eq!(w.status, Status::Pass, "{w:?}");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let file = dir.join("a.txt");
assert_eq!(dir_readable(&file).status, Status::Fail);
assert_eq!(dir_writable(&file).status, Status::Fail);
let missing = dir.join("missing");
assert_eq!(dir_readable(&missing).status, Status::Fail);
assert_eq!(dir_writable(&missing).status, Status::Fail);
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn read_only_dir_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("emperor-doctor-ro-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let probe_refused = std::fs::write(dir.join("p"), "x").is_err();
let w = dir_writable(&dir);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
if probe_refused {
assert_eq!(w.status, Status::Fail, "{w:?}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
}