mod agents;
mod collector;
pub(crate) mod dirs;
mod disk;
mod env;
mod platform;
mod process;
mod runtime;
mod secrets;
mod service;
mod spawn;
#[cfg(test)]
mod tests;
use crate::service::{Context, Ran, System};
use anyhow::Result;
use clap::Args;
use dirs::Dirs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::Duration;
#[derive(Args)]
pub struct DoctorArgs {
#[arg(long, env = "HENNERY_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long)]
collector_ca_cert: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Status {
Ok,
Warn,
Fail,
}
impl Status {
fn label(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Warn => "warn",
Self::Fail => "fail",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub number: u8,
pub name: &'static str,
pub status: Status,
pub summary: String,
pub fix: String,
details: Vec<(Status, String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Finding {
Checked(Check),
NotRun {
number: u8,
why: &'static str,
},
}
#[derive(Debug, Default)]
pub struct Verdict {
parts: Vec<(Status, String, String)>,
}
impl Verdict {
pub fn ok(&mut self, summary: impl Into<String>) {
self.parts.push((Status::Ok, summary.into(), String::new()));
}
pub fn warn(&mut self, summary: impl Into<String>, fix: impl Into<String>) {
self.parts.push((Status::Warn, summary.into(), fix.into()));
}
pub fn fail(&mut self, summary: impl Into<String>, fix: impl Into<String>) {
self.parts.push((Status::Fail, summary.into(), fix.into()));
}
pub fn check(self, number: u8, name: &'static str) -> Check {
let status = self.parts.iter().map(|p| p.0).max().unwrap_or(Status::Ok);
let summary = self
.parts
.iter()
.map(|p| p.1.as_str())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("; ");
let mut fixes: Vec<&str> = Vec::new();
for (s, _, fix) in &self.parts {
if *s == status && !fix.is_empty() && !fixes.contains(&fix.as_str()) {
fixes.push(fix);
}
}
Check {
number,
name,
status,
summary,
fix: fixes.join(" "),
details: Vec::new(),
}
}
pub fn detailed_check(self, number: u8, name: &'static str) -> Check {
let details = self.parts.clone();
let mut check = self.check(number, name);
check.details = details;
check
}
}
pub type Runner<'a> = &'a dyn Fn(&Path, &[&str]) -> Option<Ran>;
pub struct Doctor<'a> {
pub cx: &'a Context<'a>,
pub dirs: Dirs,
pub run: Runner<'a>,
pub collector_trust: hennery_host::transport::CollectorTrust,
}
impl Doctor<'_> {
pub fn agents_given(&self) -> bool {
self.given_agents().is_some()
}
pub fn given_agents(&self) -> Option<Vec<(String, hennery_host::AgentCommand)>> {
use crate::service::unit::Role;
let host = self.dirs.host.as_ref().and_then(|h| h.canonicalize().ok())?;
self.cx.installed().into_iter().find_map(|role| {
let argv = crate::service::read_command_line(self.cx, role)?;
let served = match (role, crate::service::data_dir_of(&argv)) {
(Role::Up, Some(data)) => data.join("host"),
(Role::Host, Some(data)) => data,
_ => return None,
};
if served.canonicalize().ok() != Some(host.clone()) {
return None;
}
let mut given = Vec::new();
let mut words = argv.iter();
while let Some(word) = words.next() {
let spec = match word.strip_prefix("--agent=") {
Some(spec) => Some(spec),
None if word == "--agent" => words.next().map(String::as_str),
None => None,
};
if let Some(agent) = spec.and_then(|spec| crate::parse_agent(spec).ok()) {
given.push(agent);
}
}
(!given.is_empty()).then_some(given)
})
}
}
pub fn checks(doctor: &Doctor) -> Vec<Finding> {
let [seven, eight] = collector::collector(doctor);
vec![
runtime::binary_and_set(doctor),
platform::platform(doctor),
agents::adapters_start(doctor),
agents::logged_in(doctor),
service::service_path(doctor),
env::environment(doctor),
seven,
eight,
disk::disk(doctor),
service::service(doctor),
env::hennery_on_path(doctor),
runtime::adapter_set(doctor),
agents::bundled_and_terminal(doctor),
service::host_directory(doctor),
collector::listeners(doctor),
runtime::cli_overrides(doctor),
secrets::secret_files(doctor),
]
}
pub const RUN_TIMEOUT: Duration = Duration::from_secs(5);
pub fn run_bounded(program: &Path, args: &[&str]) -> Option<Ran> {
let env = [("PATH".to_string(), "/usr/bin:/bin".to_string())];
spawn::run(program, args, &env, RUN_TIMEOUT, true)
}
pub fn run(args: DoctorArgs) -> Result<ExitCode> {
let system = System;
let cx = Context::from_process(&system)?;
let dirs = Dirs::discover(&cx, args.data_dir.as_deref())?;
let doctor = Doctor {
cx: &cx,
dirs,
run: &run_bounded,
collector_trust: args
.collector_ca_cert
.as_deref()
.map(hennery_host::transport::CollectorTrust::import)
.transpose()?
.unwrap_or_default(),
};
stop_children_on_signals()?;
let findings = checks(&doctor);
render(&doctor.dirs, &findings, &mut std::io::stdout())?;
Ok(exit_code(&findings))
}
fn stop_children_on_signals() -> Result<()> {
use tokio::signal::unix::{SignalKind, signal};
let mut interrupt = signal(SignalKind::interrupt())?;
let mut terminate = signal(SignalKind::terminate())?;
let mut hangup = signal(SignalKind::hangup())?;
tokio::spawn(async move {
tokio::select! {
_ = interrupt.recv() => {}
_ = terminate.recv() => {}
_ = hangup.recv() => {}
}
spawn::kill_all();
std::process::exit(130);
});
Ok(())
}
pub fn exit_code(findings: &[Finding]) -> ExitCode {
let failed = findings
.iter()
.any(|f| matches!(f, Finding::Checked(c) if c.status == Status::Fail));
if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS }
}
pub fn printable(text: &str) -> String {
text.chars()
.map(|c| {
let reorders = matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}');
if c.is_control() || reorders {
c.escape_default().to_string()
} else {
c.to_string()
}
})
.collect()
}
pub fn render(dirs: &Dirs, findings: &[Finding], out: &mut dyn Write) -> Result<()> {
writeln!(
out,
"hennery doctor: {} ({})",
printable(&dirs.root.display().to_string()),
dirs.from.describe()
)?;
writeln!(out, " {}", printable(&dirs.describe()))?;
for note in &dirs.notes {
writeln!(out, " note: {}", printable(note))?;
}
let mut not_run: Vec<(&'static str, Vec<u8>)> = Vec::new();
for finding in findings {
match finding {
Finding::Checked(check) => {
if !check.details.is_empty() {
writeln!(out, "{:<4} {:>2} {}:", check.status.label(), check.number, check.name)?;
for (status, summary, fix) in &check.details {
writeln!(out, " {}: {}", status.label(), printable(summary))?;
if !fix.is_empty() {
writeln!(out, " fix: {}", printable(fix))?;
}
}
continue;
}
writeln!(
out,
"{:<4} {:>2} {}: {}",
check.status.label(),
check.number,
check.name,
printable(&check.summary)
)?;
if check.status != Status::Ok {
writeln!(out, " fix: {}", printable(&check.fix))?;
}
}
Finding::NotRun { number, why } => match not_run.iter_mut().find(|(w, _)| w == why) {
Some((_, numbers)) => numbers.push(*number),
None => not_run.push((why, vec![*number])),
},
}
}
if !not_run.is_empty() {
let groups: Vec<String> = not_run
.iter()
.map(|(why, numbers)| {
let numbers: Vec<String> = numbers.iter().map(u8::to_string).collect();
format!("{} ({why})", numbers.join(", "))
})
.collect();
writeln!(out, "not run here: {}", groups.join("; "))?;
}
Ok(())
}