hyperhive/hive-c0re/src/stats/host_stats.rs

310 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Host-system probes and the derived **server-warning** list that the
//! dashboard renders as a top-of-page banner.
//!
//! The first (and currently only) producer is a host disk-usage check:
//! the nix store filling up is what ENOSPC-failed CI before the GC
//! guardrails were documented. hive-c0re runs on the host (not inside a
//! container), so it can `statvfs` the store path directly and warn the
//! operator *before* an ENOSPC, not after.
//!
//! [`server_warnings`] is the public surface: it returns a flat list of
//! [`ServerWarning`]s for `/api/state`. It is now just a snapshot of the
//! push-based [`crate::warnings`] registry — producers raise/clear their
//! own warnings via an RAII guard rather than being re-probed on every
//! render. The disk-pressure check is reworked onto that model here:
//! [`refresh_disk_warning`] drives a held guard from a periodic task (see
//! `spawn` in `main.rs`) instead of a `statvfs` per `/api/state`.
use std::collections::HashMap;
use serde::Serialize;
use utoipa::ToSchema;
use crate::container_view::ContainerView;
/// One server-level warning for the dashboard's top-of-page banner.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ServerWarning {
/// Stable kind id (e.g. `"disk_pressure"`) — lets the frontend dedupe
/// or special-case without parsing the message.
pub kind: &'static str,
/// Severity: `"warn"` (amber) or `"crit"` (red). The banner picks its
/// colour from this; everything else is just the message text.
pub level: &'static str,
/// Human-readable, already-formatted message shown in the banner.
pub message: String,
}
/// Percent-used past which the host nix store earns a disk-pressure
/// warning; above [`DISK_CRIT_PCT`] it escalates to `crit`.
const DISK_WARN_PCT: f64 = 85.0;
const DISK_CRIT_PCT: f64 = 95.0;
/// Snapshot the current server-level warnings for the dashboard banner —
/// a cheap read of the push-based [`crate::warnings`] registry. Producers
/// (the disk watch below, background converge sweeps, …) raise and clear
/// their own entries via [`crate::warnings::set_warning`]; nothing is
/// re-probed here.
#[must_use]
pub fn server_warnings() -> Vec<ServerWarning> {
crate::warnings::snapshot()
}
/// Pure disk-pressure decision: given usage, return the banner
/// `(level, message)` or `None` when below the warn threshold. Split from
/// the probe + guard plumbing so the threshold + formatting is unit-tested
/// without a real `statvfs`.
fn disk_pressure_warning(used_pct: f64, free_bytes: u64) -> Option<(&'static str, String)> {
if used_pct < DISK_WARN_PCT {
return None;
}
#[allow(
clippy::cast_precision_loss,
reason = "byte counts stay well under f64's 2^53 exact-integer range, so this GiB conversion loses no precision"
)]
let free_gib = free_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
let level = if used_pct >= DISK_CRIT_PCT {
"crit"
} else {
"warn"
};
let message = format!(
"host nix store {used_pct:.0}% full ({free_gib:.1} GiB free) \
— garbage-collect the store before it runs out of space"
);
Some((level, message))
}
/// One tick of the disk-pressure watch: probe the store and reconcile the
/// held warning `guard` — raise or refresh it while usage is over
/// threshold, drop it (clearing the banner) once back under. Driven by the
/// periodic disk-watch task spawned in `main.rs`, replacing the old
/// per-render `statvfs` in [`server_warnings`].
pub fn refresh_disk_warning(guard: &mut Option<crate::warnings::WarningGuard>) {
let decision = nix_disk_usage().and_then(|d| disk_pressure_warning(d.used_pct, d.free_bytes));
match decision {
Some((level, message)) => match guard {
Some(g) => g.update(level, message),
None => {
*guard = Some(crate::warnings::set_warning(
"disk_pressure",
level,
message,
));
}
},
// Back under threshold (or probe failed) — drop the guard to clear.
None => *guard = None,
}
}
/// Agent-state warnings derived from the live container snapshot the
/// dashboard already holds: agents that need a claude login, and agents
/// that are crashing. Kept separate from [`server_warnings`] (host
/// probes) because the caller owns the container list + crash counts;
/// the dashboard concatenates both into one banner list.
///
/// `crash_counts` is `Coordinator::recent_crash_counts(window)` — agent →
/// number of crashes inside that window — so a crash-looping agent shows
/// its repeat count rather than a single point-in-time flap.
#[must_use]
pub fn agent_state_warnings<S: std::hash::BuildHasher>(
containers: &[ContainerView],
crash_counts: &HashMap<String, usize, S>,
) -> Vec<ServerWarning> {
let mut out = Vec::new();
// `needs_login` is already running-gated in `container_view::build_all`,
// so a stopped container never lights this.
let mut pending: Vec<&str> = containers
.iter()
.filter(|c| c.needs_login)
.map(|c| c.name.as_str())
.collect();
if !pending.is_empty() {
pending.sort_unstable();
out.push(ServerWarning {
kind: "pending_logins",
level: "warn",
message: format!(
"{n} agent{plural} {verb} claude login: {list} \
— open the agent's page in the dashboard to complete the login",
n = pending.len(),
plural = if pending.len() == 1 { "" } else { "s" },
verb = if pending.len() == 1 { "needs" } else { "need" },
list = pending.join(", "),
),
});
}
if !crash_counts.is_empty() {
let mut crashing: Vec<(&String, usize)> =
crash_counts.iter().map(|(a, n)| (a, *n)).collect();
crashing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
let list = crashing
.iter()
.map(|(a, n)| format!("{a} (×{n})"))
.collect::<Vec<_>>()
.join(", ");
out.push(ServerWarning {
kind: "agents_crashing",
level: "crit",
message: format!(
"{n} agent{plural} crashing: {list} \
— check the container journal (`hivectl logs <agent>`)",
n = crashing.len(),
plural = if crashing.len() == 1 { "" } else { "s" },
),
});
}
out
}
/// Disk usage for the filesystem backing the host nix store — internal to
/// the disk-pressure producer above.
struct DiskUsage {
/// Space available to unprivileged writers, in bytes.
free_bytes: u64,
/// Percentage used, 0100. Mirrors `df`'s use% — `used / (used +
/// available)` — so the threshold means what the operator sees in `df`.
used_pct: f64,
}
/// Probe disk usage for the filesystem containing the nix store (`/nix`),
/// falling back to `/` when `/nix` isn't its own mount. Returns `None` if
/// the `statvfs` syscall fails (path missing, permission, etc.).
fn nix_disk_usage() -> Option<DiskUsage> {
disk_usage("/nix").or_else(|| disk_usage("/"))
}
fn disk_usage(path: &str) -> Option<DiskUsage> {
let c_path = std::ffi::CString::new(path).ok()?;
// SAFETY: `statvfs` reads only through the valid NUL-terminated
// `c_path` pointer and writes into the zeroed `stat` we own. We check
// the return code before reading any field.
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut stat) };
if rc != 0 {
return None;
}
// statvfs fields are `c_ulong` (== u64 on the x86_64-linux host this
// runs on); the arithmetic below stays in that native width.
let frsize = stat.f_frsize;
let total_blocks = stat.f_blocks;
let free_blocks = stat.f_bfree;
let avail_blocks = stat.f_bavail;
if total_blocks == 0 || frsize == 0 {
return None;
}
let free_bytes = avail_blocks.saturating_mul(frsize);
// df's use%: used / (used + available). `used` counts root-reserved
// blocks (total - bfree); `available` is the unprivileged free
// (bavail), so the percentage matches what `df` reports.
let used_blocks = total_blocks.saturating_sub(free_blocks);
let capacity = used_blocks.saturating_add(avail_blocks);
let used_pct = if capacity == 0 {
0.0
} else {
#[allow(
clippy::cast_precision_loss,
reason = "block counts stay well under f64's 2^53 exact-integer range, so this percentage computation loses no precision"
)]
let raw = used_blocks as f64 / capacity as f64 * 100.0;
raw
};
Some(DiskUsage {
free_bytes,
used_pct,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disk_below_threshold_is_none() {
assert!(disk_pressure_warning(84.9, 100 << 30).is_none());
}
#[test]
fn disk_warn_band_is_amber() {
let (level, msg) = disk_pressure_warning(90.0, 20 << 30).expect("warn");
assert_eq!(level, "warn");
assert!(msg.contains("90% full"));
assert!(msg.contains("20.0 GiB free"));
}
#[test]
fn disk_crit_band_is_red() {
let (level, _) = disk_pressure_warning(95.0, 1 << 30).expect("crit");
assert_eq!(level, "crit");
}
fn cv(name: &str, needs_login: bool) -> ContainerView {
ContainerView {
name: name.to_owned(),
container: format!("h-{name}"),
port: 0,
running: true,
needs_update: false,
needs_login,
deployed_sha: None,
parent: None,
active_model: None,
paused: false,
cpu_quota: "200%".to_owned(),
memory_max: "4G".to_owned(),
}
}
#[test]
fn no_agent_warnings_when_all_healthy() {
let containers = [cv("alice", false), cv("bob", false)];
assert!(agent_state_warnings(&containers, &HashMap::new()).is_empty());
}
#[test]
fn pending_logins_lists_sorted_agents() {
let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)];
let w = agent_state_warnings(&containers, &HashMap::new());
assert_eq!(w.len(), 1);
assert_eq!(w[0].kind, "pending_logins");
assert_eq!(w[0].level, "warn");
// sorted, login-needing only, count reflected
assert!(
w[0].message
.starts_with("2 agents need claude login: bob, zoe")
);
}
#[test]
fn singular_grammar_for_one_agent() {
let containers = [cv("solo", true)];
let w = agent_state_warnings(&containers, &HashMap::new());
assert!(w[0].message.starts_with("1 agent needs claude login: solo"));
}
#[test]
fn crashing_warning_is_crit_and_count_ordered() {
let crashes = HashMap::from([("flap".to_owned(), 5), ("blip".to_owned(), 1)]);
let w = agent_state_warnings(&[], &crashes);
assert_eq!(w.len(), 1);
assert_eq!(w[0].kind, "agents_crashing");
assert_eq!(w[0].level, "crit");
// higher crash count first
assert!(w[0].message.contains("flap (×5), blip (×1)"));
}
#[test]
fn both_warnings_coexist() {
let containers = [cv("a", true)];
let crashes = HashMap::from([("b".to_owned(), 2)]);
let kinds: Vec<&str> = agent_state_warnings(&containers, &crashes)
.iter()
.map(|w| w.kind)
.collect();
assert_eq!(kinds, ["pending_logins", "agents_crashing"]);
}
}