crates/gunnar-server/src/dashboard/probe.rs
//! **What the header band says about the machine**, read the cheap way.
//!
//! Every number comes from a file under `/proc`, one `statfs`, or one
//! `RTM_GETADDR` dump over the same `NETLINK_ROUTE` socket family
//! edda's `appliance_pid1::net` configures the interface with. No `sysinfo`, no `libc`
//! walking, nothing that needs `/sys` (which on the appliance may be a bare
//! mount point) — and every reader returns `Option`/an empty list on any
//! failure, because the screen is decoration and a missing number is shown
//! as a dash rather than becoming a reason PID 1 is unhappy.
//!
//! The parsers are pure over `&str` so the tests feed them recorded `/proc`
//! text (measured: the assertion is on the parsed value, not on "it did not
//! error").
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
// The netlink framing (lane G6): edda's own encoders, public since appliance-pid1
// 0.1.5, so this read side keeps no copy of them.
use appliance_pid1::net::{align4, netlink_message, netlink_socket};
/// A jiffies snapshot of the first `cpu` line of `/proc/stat`, for the busy
/// percentage between two reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CpuTicks {
pub busy: u64,
pub total: u64,
}
impl CpuTicks {
/// Busy share of the interval between `earlier` and `self`, 0–100.
/// `None` when the interval is empty (two identical reads, or the first
/// tick).
pub fn busy_percent_since(self, earlier: CpuTicks) -> Option<u8> {
let total = self.total.checked_sub(earlier.total)?;
if total == 0 {
return None;
}
let busy = self.busy.saturating_sub(earlier.busy);
Some((busy * 100 / total).min(100) as u8)
}
}
/// `/proc/stat`'s aggregate `cpu` line → ticks, and how many `cpuN` lines
/// follow it (= online CPUs).
pub fn parse_stat(text: &str) -> (Option<CpuTicks>, usize) {
let mut ticks = None;
let mut cpus = 0;
for line in text.lines() {
let mut parts = line.split_ascii_whitespace();
let Some(name) = parts.next() else { continue };
if name == "cpu" {
let v: Vec<u64> = parts.filter_map(|p| p.parse().ok()).collect();
// user nice system idle iowait irq softirq steal …
if v.len() >= 4 {
let total: u64 = v.iter().sum();
let idle = v[3] + v.get(4).copied().unwrap_or(0);
ticks = Some(CpuTicks {
busy: total.saturating_sub(idle),
total,
});
}
} else if name.starts_with("cpu") && name[3..].chars().all(|c| c.is_ascii_digit()) {
cpus += 1;
}
}
(ticks, cpus)
}
/// `(MemTotal, MemAvailable)` in bytes out of `/proc/meminfo`.
pub fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
let mut total = None;
let mut avail = None;
for line in text.lines() {
let (key, rest) = line.split_once(':')?;
let kib: u64 = rest.split_ascii_whitespace().next()?.parse().ok()?;
match key {
"MemTotal" => total = Some(kib * 1024),
"MemAvailable" => avail = Some(kib * 1024),
_ => {}
}
if total.is_some() && avail.is_some() {
break;
}
}
Some((total?, avail?))
}
/// Whole seconds out of `/proc/uptime`.
pub fn parse_uptime(text: &str) -> Option<u64> {
let first = text.split_ascii_whitespace().next()?;
let secs: f64 = first.parse().ok()?;
Some(secs as u64)
}
/// The 1-minute load out of `/proc/loadavg`, as printed.
pub fn parse_loadavg(text: &str) -> Option<String> {
text.split_ascii_whitespace().next().map(str::to_owned)
}
/// The first `model name` out of `/proc/cpuinfo`, shortened: vendor noise
/// (`(R)`, `(TM)`, `CPU @ 2.60GHz`, `Processor`) stripped, so it fits a cell.
///
/// `N-Core` / `N-Cores` goes too: it is the HOST chip's core count, and on a
/// 4-vCPU cloud plan `4x AMD EPYC 7542 32-Core` read as two contradicting
/// numbers (measured on UpCloud 2026-08-29). The vCPU count the band prints
/// in front of the name is the one that is true of this box.
pub fn parse_cpu_model(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.starts_with("model name"))?;
let (_, value) = line.split_once(':')?;
let mut out = String::new();
for word in value.split_ascii_whitespace() {
let w = word
.replace("(R)", "")
.replace("(TM)", "")
.replace("(tm)", "");
if w.is_empty()
|| w == "CPU"
|| w == "Processor"
|| w == "@"
|| w.ends_with("GHz")
|| is_core_count(&w)
{
continue;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&w);
}
if out.is_empty() {
None
} else {
Some(out)
}
}
/// `32-Core` / `64-Cores`: the host chip's core count in a model name.
fn is_core_count(word: &str) -> bool {
let Some(digits) = word
.strip_suffix("-Cores")
.or_else(|| word.strip_suffix("-Core"))
else {
return false;
};
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}
// ── what the data root IS: /proc/mounts + the md sysfs ──────────────────────
/// The filesystem line of `/proc/mounts` for one mount point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountFacts {
/// `/dev/md127`, `/dev/md/korp-data`, `/dev/vda3`, …
pub source: String,
/// `xfs`, `vfat`, …
pub fstype: String,
}
/// The LAST `/proc/mounts` line whose mount point is `mountpoint` (a later
/// mount shadows an earlier one at the same path). Octal escapes in the path
/// (`\040` for a space) are decoded so `/data` matches only `/data`.
pub fn parse_mounts(text: &str, mountpoint: &str) -> Option<MountFacts> {
let mut found = None;
for line in text.lines() {
let mut f = line.split_ascii_whitespace();
let (Some(source), Some(target), Some(fstype)) = (f.next(), f.next(), f.next()) else {
continue;
};
if unescape_mount(target) == mountpoint {
found = Some(MountFacts {
source: unescape_mount(source),
fstype: fstype.to_owned(),
});
}
}
found
}
fn unescape_mount(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'\\'
&& i + 3 < b.len()
&& b[i + 1..i + 4].iter().all(|c| (b'0'..=b'7').contains(c))
{
let v = (b[i + 1] - b'0') * 64 + (b[i + 2] - b'0') * 8 + (b[i + 3] - b'0');
out.push(v);
i += 4;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// An md array's shape as its sysfs `md/` directory says it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetFacts {
/// `raid0`, `raid1`, …
pub level: String,
/// `md/raid_disks`.
pub members: usize,
/// The smallest member's share of the array, bytes (`md/dev-*/size` is
/// KiB); members are equal by korp-installer's rule, so this is the size
/// a human recognises as "the disk".
pub member_bytes: u64,
/// `md/chunk_size`, bytes.
pub chunk_bytes: u64,
}
/// Read one array off its sysfs block directory (`/sys/block/md127`,
/// `/sys/dev/block/9:127`): `md/level`, `md/raid_disks`, `md/chunk_size`,
/// and every `md/dev-*/size`. `None` unless all of level, count and at least
/// one member size are there — a half-known set is printed as nothing, never
/// as `0×0G` (the identity-value trap: measure, never assert).
pub fn read_set_facts(block_dir: &std::path::Path) -> Option<SetFacts> {
let md = block_dir.join("md");
let text = |name: &str| {
std::fs::read_to_string(md.join(name))
.ok()
.map(|t| t.trim().to_owned())
};
let level = text("level").filter(|l| !l.is_empty())?;
let members: usize = text("raid_disks")?.parse().ok().filter(|&n| n > 0)?;
let chunk_bytes: u64 = text("chunk_size").and_then(|c| c.parse().ok()).unwrap_or(0);
let mut smallest: Option<u64> = None;
for entry in std::fs::read_dir(&md).ok()? {
let Ok(entry) = entry else { continue };
let name = entry.file_name();
let Some(dev) = name.to_str().and_then(|n| n.strip_prefix("dev-")) else {
continue;
};
let kib = std::fs::read_to_string(entry.path().join("size"))
.ok()
.and_then(|t| t.trim().parse::<u64>().ok())
.map(|k| k * 1024)
.or_else(|| {
// Fallback: the member's own block size, in 512-byte sectors.
std::fs::read_to_string(block_dir.join("../../class/block").join(dev).join("size"))
.ok()
.and_then(|t| t.trim().parse::<u64>().ok())
.map(|s| s * 512)
});
if let Some(b) = kib.filter(|&b| b > 0) {
smallest = Some(smallest.map_or(b, |s| s.min(b)));
}
}
Some(SetFacts {
level,
members,
member_bytes: smallest?,
chunk_bytes,
})
}
/// What the data root IS, for the `disk` cell: the filesystem, and the array
/// under it when the source is an md device.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiskKind {
/// `xfs`, `fat` (the screen's word for `vfat`/`msdos`), else as mounted.
pub fs: String,
pub set: Option<SetFacts>,
}
impl DiskKind {
pub fn from_mount(mount: &MountFacts, set: Option<SetFacts>) -> Self {
let fs = match mount.fstype.as_str() {
"vfat" | "msdos" | "fat" => "fat".to_owned(),
other => other.to_owned(),
};
DiskKind { fs, set }
}
}
impl std::fmt::Display for DiskKind {
/// `4×75G raid0 xfs` / `xfs` / `fat`.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(set) = &self.set {
write!(
f,
"{}\u{d7}{} {} ",
set.members,
crate::dashboard::fmt_bytes(set.member_bytes),
set.level
)?;
}
f.write_str(&self.fs)
}
}
/// The sysfs block directory of the device node at `source`, through
/// `/sys/dev/block/<major>:<minor>` — the one door that works whatever the
/// node is called (`/dev/md127`, `/dev/md/korp-data` mknod'ed by korp-start).
fn sysfs_block_dir_of(source: &str) -> Option<std::path::PathBuf> {
use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
let meta = std::fs::metadata(source).ok()?;
if !meta.file_type().is_block_device() {
return None;
}
let rdev = meta.rdev();
let dir = std::path::PathBuf::from(format!(
"/sys/dev/block/{}:{}",
rustix::fs::major(rdev),
rustix::fs::minor(rdev)
));
dir.is_dir().then_some(dir)
}
/// Live: `/proc/mounts` for `mountpoint`, then the md sysfs when the source
/// is an array. `None` when the path is not a mount point of its own (a
/// developer's `--data-root /tmp/x`), so the cell appends nothing.
pub fn disk_kind(mountpoint: &std::path::Path) -> Option<DiskKind> {
let mount = parse_mounts(&read("/proc/mounts")?, mountpoint.to_str()?)?;
let set = sysfs_block_dir_of(&mount.source).and_then(|d| read_set_facts(&d));
Some(DiskKind::from_mount(&mount, set))
}
/// One address on one interface, as the screen prints it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IfAddr {
pub interface: String,
pub address: IpAddr,
pub prefix: u8,
}
impl std::fmt::Display for IfAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}/{}", self.interface, self.address, self.prefix)
}
}
// ── netlink RTM_GETADDR, the dump ───────────────────────────────────────────
const RTM_GETADDR: u16 = 22;
const RTM_NEWADDR: u16 = 20;
const NLMSG_DONE: u16 = 3;
const NLMSG_ERROR: u16 = 2;
const NLM_F_REQUEST: u16 = 0x0001;
const NLM_F_DUMP: u16 = 0x0300;
const NLM_F_MULTI: u16 = 0x0002;
const AF_INET: u8 = 2;
const AF_INET6: u8 = 10;
const IFA_ADDRESS: u16 = 1;
const IFA_LOCAL: u16 = 2;
const IFA_LABEL: u16 = 3;
/// `RT_SCOPE_HOST`: loopback and its kin — not something a client dials.
const RT_SCOPE_HOST: u8 = 254;
/// `RT_SCOPE_LINK`: `fe80::/10` — real, but not the address anyone types.
const RT_SCOPE_LINK: u8 = 253;
/// The `RTM_GETADDR` dump request for `family` (`AF_UNSPEC` = 0 for all).
pub fn getaddr_dump_message(seq: u32) -> Vec<u8> {
// ifaddrmsg: family, prefixlen, flags, scope, index — 8 bytes, all zero
// for a dump.
netlink_message(RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, seq, &[0u8; 8])
}
/// Every `RTM_NEWADDR` in one or more dump answers → addresses worth showing:
/// host scope (loopback) and link scope (`fe80::`) are left out. Interface
/// names come from `IFA_LABEL` (IPv4 carries it) or from `index_to_name`
/// through `resolve`, so this parser is pure over bytes.
///
/// Returns `(addresses, done)`; `done` is true once `NLMSG_DONE` was seen.
pub fn parse_getaddr_reply(
buf: &[u8],
resolve: &mut dyn FnMut(u32) -> Option<String>,
) -> (Vec<IfAddr>, bool) {
let mut out = Vec::new();
let mut off = 0usize;
let mut done = false;
while off + 16 <= buf.len() {
let len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) as usize;
let kind = u16::from_le_bytes([buf[off + 4], buf[off + 5]]);
let flags = u16::from_le_bytes([buf[off + 6], buf[off + 7]]);
if len < 16 || off + len > buf.len() {
break;
}
match kind {
NLMSG_DONE => {
done = true;
break;
}
NLMSG_ERROR => {
done = true;
break;
}
RTM_NEWADDR if len >= 24 => {
let body = &buf[off + 16..off + len];
let family = body[0];
let prefix = body[1];
let scope = body[3];
let index = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
if scope != RT_SCOPE_HOST && scope != RT_SCOPE_LINK {
let mut address = None;
let mut local = None;
let mut label = None;
let mut a = 8usize;
while a + 4 <= body.len() {
let alen = u16::from_le_bytes([body[a], body[a + 1]]) as usize;
let atype = u16::from_le_bytes([body[a + 2], body[a + 3]]);
if alen < 4 || a + alen > body.len() {
break;
}
let payload = &body[a + 4..a + alen];
match atype {
IFA_ADDRESS => address = ip_from(family, payload),
IFA_LOCAL => local = ip_from(family, payload),
IFA_LABEL => {
let end = payload
.iter()
.position(|&b| b == 0)
.unwrap_or(payload.len());
label = String::from_utf8(payload[..end].to_vec()).ok();
}
_ => {}
}
a += align4(alen);
}
// IFA_LOCAL is the interface's own address on a
// point-to-point link; IFA_ADDRESS is the peer there and
// the same address everywhere else.
if let Some(ip) = local.or(address) {
let interface = label
.or_else(|| resolve(index))
.unwrap_or_else(|| format!("if{index}"));
out.push(IfAddr {
interface,
address: ip,
prefix,
});
}
}
}
_ => {}
}
if flags & NLM_F_MULTI == 0 {
// A single (non-multipart) answer ends here.
done = true;
}
off += align4(len);
}
(out, done)
}
fn ip_from(family: u8, payload: &[u8]) -> Option<IpAddr> {
match (family, payload.len()) {
(AF_INET, 4) => Some(IpAddr::V4(Ipv4Addr::new(
payload[0], payload[1], payload[2], payload[3],
))),
(AF_INET6, 16) => {
let mut b = [0u8; 16];
b.copy_from_slice(payload);
Some(IpAddr::V6(Ipv6Addr::from(b)))
}
_ => None,
}
}
/// The live dump: one request, answers until `NLMSG_DONE`. Empty on any
/// failure — the screen shows no addresses rather than a wrong one.
pub fn addresses() -> Vec<IfAddr> {
use rustix::net::{
netdevice, recv, send, socket, AddressFamily, RecvFlags, SendFlags, SocketType,
};
let Ok(sock) = netlink_socket() else {
return Vec::new();
};
if send(&sock, &getaddr_dump_message(1), SendFlags::empty()).is_err() {
return Vec::new();
}
let probe = socket(AddressFamily::INET, SocketType::DGRAM, None).ok();
let mut resolve = |index: u32| -> Option<String> {
let probe = probe.as_ref()?;
netdevice::index_to_name(probe, index).ok()
};
let mut out = Vec::new();
let mut buf = vec![0u8; 16384];
// A dump is a bounded number of datagrams; 64 is far past any real box.
for _ in 0..64 {
let Ok((n, _)) = recv(&sock, &mut buf[..], RecvFlags::empty()) else {
break;
};
if n == 0 {
break;
}
let (mut got, done) = parse_getaddr_reply(&buf[..n], &mut resolve);
out.append(&mut got);
if done {
break;
}
}
out
}
// ── the readers ─────────────────────────────────────────────────────────────
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
pub fn hostname() -> Option<String> {
let h = read("/proc/sys/kernel/hostname")?;
let h = h.trim();
if h.is_empty() || h == "(none)" {
None
} else {
Some(h.to_owned())
}
}
pub fn cpu_ticks() -> (Option<CpuTicks>, usize) {
read("/proc/stat")
.map(|t| parse_stat(&t))
.unwrap_or((None, 0))
}
pub fn memory() -> Option<(u64, u64)> {
meminfo().ok()
}
/// `(MemTotal, MemAvailable)` in bytes, or **why not**. The band shows a dash
/// for a missing number; `Admin.Capacity` must name the cause of an absence,
/// so this is the reader both go through — [`memory`] is its `.ok()`.
pub fn meminfo() -> Result<(u64, u64), String> {
let text = read_why("/proc/meminfo")?;
parse_meminfo(&text)
.ok_or_else(|| "/proc/meminfo has no MemTotal and MemAvailable lines to parse".to_owned())
}
/// The 1-minute load out of `/proc/loadavg` as a number, or why not.
/// [`load1`] is the band's string of the same field.
pub fn load1_value() -> Result<f64, String> {
let text = read_why("/proc/loadavg")?;
parse_loadavg(&text)
.and_then(|first| first.parse::<f64>().ok())
.ok_or_else(|| "/proc/loadavg does not start with a number".to_owned())
}
/// [`read`] that keeps the OS's reason, for the readers whose caller reports
/// an absence rather than drawing a dash.
fn read_why(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|err| format!("{path} could not be read ({err})"))
}
pub fn uptime_secs() -> Option<u64> {
parse_uptime(&read("/proc/uptime")?)
}
pub fn load1() -> Option<String> {
parse_loadavg(&read("/proc/loadavg")?)
}
pub fn cpu_model() -> Option<String> {
parse_cpu_model(&read("/proc/cpuinfo")?)
}
/// One live index row's facts, engine-neutral: the repository's git dir and
/// the engine's snapshot. Rate is the screen's to derive across two ticks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexFacts {
pub git_dir: std::path::PathBuf,
pub snap: crate::storage::IndexSnapshot,
}
/// Every open repository's index work, active or not. A few atomics per open
/// store; no store lock, no `/proc`.
pub fn index_progress(engine: crate::storage::Engine) -> Vec<IndexFacts> {
engine
.index_progress()
.into_iter()
.map(|(git_dir, snap)| IndexFacts { git_dir, snap })
.collect()
}
/// `(total, available)` bytes of the filesystem holding `path`.
pub fn disk(path: &std::path::Path) -> Option<(u64, u64)> {
let st = rustix::fs::statfs(path).ok()?;
let bsize = st.f_frsize as u64;
Some((st.f_blocks * bsize, st.f_bavail * bsize))
}
#[cfg(test)]
mod tests {
use super::*;
use appliance_pid1::net::rtattr;
const STAT: &str = "cpu 4705 0 3540 178540 320 0 87 0 0 0\ncpu0 2000 0 1700 89000 200 0 40 0 0 0\ncpu1 2705 0 1840 89540 120 0 47 0 0 0\nintr 12345\nctxt 1\n";
#[test]
fn stat_counts_cpus_and_turns_two_reads_into_a_busy_percent() {
let (t, cpus) = parse_stat(STAT);
assert_eq!(cpus, 2);
let t = t.unwrap();
assert_eq!(t.total, 4705 + 3540 + 178540 + 320 + 87);
assert_eq!(t.busy, 4705 + 3540 + 87);
let later = CpuTicks {
busy: t.busy + 50,
total: t.total + 200,
};
assert_eq!(later.busy_percent_since(t), Some(25));
assert_eq!(
t.busy_percent_since(t),
None,
"an empty interval is no percentage"
);
assert_eq!(
t.busy_percent_since(later),
None,
"a wrapped/earlier read is refused, not negative"
);
}
#[test]
fn meminfo_gives_total_and_available_in_bytes() {
let text = "MemTotal: 16303748 kB\nMemFree: 1234 kB\nMemAvailable: 15000000 kB\nBuffers: 1 kB\n";
assert_eq!(
parse_meminfo(text),
Some((16303748 * 1024, 15000000 * 1024))
);
assert_eq!(
parse_meminfo("MemTotal: 1 kB\n"),
None,
"a file without MemAvailable is not guessed at"
);
}
#[test]
fn uptime_load_and_model_parse_the_real_shapes() {
assert_eq!(parse_uptime("350735.47 234388.90\n"), Some(350735));
assert_eq!(
parse_loadavg("0.08 0.03 0.01 1/412 12345\n"),
Some("0.08".into())
);
assert_eq!(parse_cpu_model("processor: 0\n"), None);
}
/// The model cell: what `/proc/cpuinfo` says → what the band prints.
/// The host chip's `N-Core` is not this box's CPU count (a 4-vCPU plan
/// on an EPYC 7542 said `4x … 32-Core` on UpCloud, 2026-08-29).
#[test]
fn the_cpu_model_is_trimmed_of_suffixes_that_are_not_the_chip_name() {
let table = [
("AMD EPYC 7542 32-Core Processor", "AMD EPYC 7542"),
(
"AMD Ryzen Threadripper PRO 3975WX 32-Cores",
"AMD Ryzen Threadripper PRO 3975WX",
),
(
"Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
"Intel Xeon E5-2680 v4",
),
(
"Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
"Intel Core i7-8650U",
),
(
"Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
"Intel Xeon Platinum 8375C",
),
("QEMU Virtual CPU version 2.5+", "QEMU Virtual version 2.5+"),
("ARM Cortex-A72", "ARM Cortex-A72"),
];
for (raw, want) in table {
let cpuinfo = format!("processor\t: 0\nvendor_id\t: x\nmodel name\t: {raw}\n");
assert_eq!(parse_cpu_model(&cpuinfo).as_deref(), Some(want), "{raw}");
}
assert!(
is_core_count("32-Core")
&& is_core_count("64-Cores")
&& !is_core_count("-Core")
&& !is_core_count("Cortex-A72")
);
}
/// One RTM_NEWADDR with IFA_ADDRESS + IFA_LABEL, in a multipart answer
/// followed by DONE.
fn newaddr(
family: u8,
prefix: u8,
scope: u8,
index: u32,
addr: &[u8],
label: Option<&str>,
) -> Vec<u8> {
let mut body = vec![family, prefix, 0, scope];
body.extend_from_slice(&index.to_le_bytes());
body.extend_from_slice(&rtattr(IFA_ADDRESS, addr));
if let Some(l) = label {
let mut z = l.as_bytes().to_vec();
z.push(0);
body.extend_from_slice(&rtattr(IFA_LABEL, &z));
}
netlink_message(RTM_NEWADDR, NLM_F_MULTI, 1, &body)
}
fn done() -> Vec<u8> {
netlink_message(NLMSG_DONE, NLM_F_MULTI, 1, &[0u8; 4])
}
#[test]
fn the_dump_request_is_a_request_plus_dump_over_an_empty_ifaddrmsg() {
let m = getaddr_dump_message(7);
assert_eq!(m.len(), 24);
assert_eq!(u16::from_le_bytes([m[4], m[5]]), RTM_GETADDR);
assert_eq!(u16::from_le_bytes([m[6], m[7]]), NLM_F_REQUEST | NLM_F_DUMP);
assert_eq!(&m[16..], &[0u8; 8]);
}
#[test]
fn a_dump_reply_yields_the_dialable_addresses_and_skips_loopback_and_link_local() {
let mut buf = Vec::new();
buf.extend(newaddr(
AF_INET,
8,
RT_SCOPE_HOST,
1,
&[127, 0, 0, 1],
Some("lo"),
));
buf.extend(newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0")));
buf.extend(newaddr(
AF_INET6,
64,
RT_SCOPE_LINK,
2,
&[0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
None,
));
buf.extend(newaddr(
AF_INET6,
64,
0,
2,
&[0x20, 0x01, 0xd, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
None,
));
buf.extend(done());
let mut resolved = Vec::new();
let (addrs, finished) = parse_getaddr_reply(&buf, &mut |i| {
resolved.push(i);
Some(format!("if{i}-resolved"))
});
assert!(finished);
assert_eq!(
addrs.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
["eth0 10.0.2.15/24", "if2-resolved 2001:db8::1/64"]
);
assert_eq!(
resolved,
[2],
"only the label-less address asked for a name"
);
}
#[test]
fn a_reply_without_done_says_so_and_a_truncated_one_stops_cleanly() {
let full = newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0"));
let (addrs, finished) = parse_getaddr_reply(&full, &mut |_| None);
assert_eq!(addrs.len(), 1);
assert!(!finished, "multipart without DONE: more datagrams follow");
let (addrs, _) = parse_getaddr_reply(&full[..full.len() - 3], &mut |_| None);
assert!(
addrs.is_empty(),
"a message whose declared length overruns the buffer is refused"
);
}
#[test]
fn the_live_readers_answer_on_this_box() {
// Not a fixture: these read the running kernel. The assertion is on
// shape — a Linux box has ≥1 CPU, RAM, and an uptime.
let (ticks, cpus) = cpu_ticks();
assert!(ticks.is_some() && cpus >= 1);
let (total, avail) = memory().expect("meminfo");
assert!(total >= avail && total > 0);
assert!(uptime_secs().is_some());
let (dt, da) = disk(std::path::Path::new("/")).expect("statfs /");
assert!(dt >= da && dt > 0);
}
}
// ── PCI hot-plug: can a member leave and come back while gunnar runs? ──────
/// **Does the RUNNING kernel have PCI hot-plug?** Measured, never declared.
///
/// Why it matters: UpCloud resizes only DETACHED volumes, so a runtime growth
/// is detach → resize → re-attach, and a kernel with `# CONFIG_HOTPLUG_PCI is
/// not set` never sees the member leave or come back (`511 HOTPLUG_FAILED`).
///
/// The rule, in order — both work on a no-OS PID 1 with no shell and a kernel
/// built with `CONFIG_MODULES=n`:
///
/// 1. `/proc/config.gz` (only when the kernel has `CONFIG_IKCONFIG_PROC`;
/// `tunnr-kernel`'s `nuked` preset does NOT): `CONFIG_HOTPLUG_PCI=y` AND at
/// least one controller (`_ACPI`, `_PCIE`, `_SHPC`) `=y`.
/// 2. Otherwise the built-in controllers' parameter directories:
/// `/sys/module/acpiphp` (`acpiphp.disable`), `/sys/module/pciehp`
/// (`pciehp.pciehp_poll_mode`), `/sys/module/shpchp` (`shpchp.shpchp_debug`).
/// `param_sysfs_builtin` creates these for built-in code with parameters
/// whether or not modules exist; nothing else in the tree creates them.
///
/// NOT used: `/sys/bus/pci/slots` — `drivers/pci/slot.o` builds on `SYSFS`
/// alone, so the directory exists on a kernel with no hot-plug at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PciHotplug {
pub present: bool,
/// What was read, for the reason line: `config.gz: …` or `sys/module: …`.
pub evidence: String,
}
/// The built-in hot-plug controllers whose `/sys/module/<name>` directory is
/// the fallback evidence.
pub const HOTPLUG_CONTROLLERS: [&str; 3] = ["acpiphp", "pciehp", "shpchp"];
/// Pure over what was read: the decompressed `/proc/config.gz` (if any) and a
/// predicate for `/sys/module/<name>` existing.
pub fn decide_pci_hotplug(config: Option<&str>, has_module: &dyn Fn(&str) -> bool) -> PciHotplug {
if let Some(text) = config {
let y = |sym: &str| text.lines().any(|l| l.trim() == format!("{sym}=y"));
let controllers: Vec<&str> = [
"CONFIG_HOTPLUG_PCI_ACPI",
"CONFIG_HOTPLUG_PCI_PCIE",
"CONFIG_HOTPLUG_PCI_SHPC",
]
.into_iter()
.filter(|s| y(s))
.collect();
let present = y("CONFIG_HOTPLUG_PCI") && !controllers.is_empty();
return PciHotplug {
present,
evidence: if present {
format!("config.gz: CONFIG_HOTPLUG_PCI=y, {}", controllers.join(" "))
} else {
"config.gz: CONFIG_HOTPLUG_PCI or every controller is not set".to_owned()
},
};
}
let found: Vec<&str> = HOTPLUG_CONTROLLERS
.into_iter()
.filter(|m| has_module(m))
.collect();
PciHotplug {
present: !found.is_empty(),
evidence: if found.is_empty() {
"sys/module: no acpiphp, pciehp or shpchp (no /proc/config.gz)".to_owned()
} else {
format!("sys/module: {} (no /proc/config.gz)", found.join(" "))
},
}
}
/// Read it off the running machine.
pub fn pci_hotplug() -> PciHotplug {
use std::io::Read as _;
let config = std::fs::File::open("/proc/config.gz").ok().and_then(|f| {
let mut s = String::new();
flate2::read::GzDecoder::new(f)
.read_to_string(&mut s)
.ok()
.map(|_| s)
});
decide_pci_hotplug(config.as_deref(), &|m| {
std::path::Path::new("/sys/module").join(m).is_dir()
})
}
#[cfg(test)]
mod pci_hotplug_tests {
use super::*;
#[test]
fn config_gz_decides_when_present_and_needs_a_controller() {
let none = |_: &str| true; // the fallback must not be consulted
let hot = "CONFIG_PCI=y\nCONFIG_HOTPLUG_PCI=y\nCONFIG_HOTPLUG_PCI_ACPI=y\n";
let p = decide_pci_hotplug(Some(hot), &none);
assert!(
p.present && p.evidence.contains("HOTPLUG_PCI_ACPI"),
"{p:?}"
);
assert!(
!decide_pci_hotplug(Some("CONFIG_HOTPLUG_PCI=y\n"), &none).present,
"the core without a controller acknowledges nothing"
);
assert!(
!decide_pci_hotplug(
Some("# CONFIG_HOTPLUG_PCI is not set\nCONFIG_HOTPLUG_PCI_ACPI=y\n"),
&none
)
.present
);
}
#[test]
fn without_config_gz_the_builtin_controllers_decide() {
let p = decide_pci_hotplug(None, &|m| m == "pciehp" || m == "acpiphp");
assert!(
p.present && p.evidence == "sys/module: acpiphp pciehp (no /proc/config.gz)",
"{p:?}"
);
let p = decide_pci_hotplug(None, &|_| false);
assert!(!p.present && p.evidence.contains("no acpiphp"), "{p:?}");
}
}