Skip to main content

gunnar_server/dashboard/
probe.rs

1//! **What the header band says about the machine**, read the cheap way.
2//!
3//! Every number comes from a file under `/proc`, one `statfs`, or one
4//! `RTM_GETADDR` dump over the same `NETLINK_ROUTE` socket family
5//! edda's `appliance_pid1::net` configures the interface with. No `sysinfo`, no `libc`
6//! walking, nothing that needs `/sys` (which on the appliance may be a bare
7//! mount point) — and every reader returns `Option`/an empty list on any
8//! failure, because the screen is decoration and a missing number is shown
9//! as a dash rather than becoming a reason PID 1 is unhappy.
10//!
11//! The parsers are pure over `&str` so the tests feed them recorded `/proc`
12//! text (measured: the assertion is on the parsed value, not on "it did not
13//! error").
14
15use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
16
17// The netlink framing (lane G6): edda's own encoders, public since appliance-pid1
18// 0.1.5, so this read side keeps no copy of them.
19use appliance_pid1::net::{align4, netlink_message, netlink_socket};
20
21/// A jiffies snapshot of the first `cpu` line of `/proc/stat`, for the busy
22/// percentage between two reads.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub struct CpuTicks {
25    pub busy: u64,
26    pub total: u64,
27}
28
29impl CpuTicks {
30    /// Busy share of the interval between `earlier` and `self`, 0–100.
31    /// `None` when the interval is empty (two identical reads, or the first
32    /// tick).
33    pub fn busy_percent_since(self, earlier: CpuTicks) -> Option<u8> {
34        let total = self.total.checked_sub(earlier.total)?;
35        if total == 0 {
36            return None;
37        }
38        let busy = self.busy.saturating_sub(earlier.busy);
39        Some((busy * 100 / total).min(100) as u8)
40    }
41}
42
43/// `/proc/stat`'s aggregate `cpu` line → ticks, and how many `cpuN` lines
44/// follow it (= online CPUs).
45pub fn parse_stat(text: &str) -> (Option<CpuTicks>, usize) {
46    let mut ticks = None;
47    let mut cpus = 0;
48    for line in text.lines() {
49        let mut parts = line.split_ascii_whitespace();
50        let Some(name) = parts.next() else { continue };
51        if name == "cpu" {
52            let v: Vec<u64> = parts.filter_map(|p| p.parse().ok()).collect();
53            // user nice system idle iowait irq softirq steal …
54            if v.len() >= 4 {
55                let total: u64 = v.iter().sum();
56                let idle = v[3] + v.get(4).copied().unwrap_or(0);
57                ticks = Some(CpuTicks {
58                    busy: total.saturating_sub(idle),
59                    total,
60                });
61            }
62        } else if name.starts_with("cpu") && name[3..].chars().all(|c| c.is_ascii_digit()) {
63            cpus += 1;
64        }
65    }
66    (ticks, cpus)
67}
68
69/// `(MemTotal, MemAvailable)` in bytes out of `/proc/meminfo`.
70pub fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
71    let mut total = None;
72    let mut avail = None;
73    for line in text.lines() {
74        let (key, rest) = line.split_once(':')?;
75        let kib: u64 = rest.split_ascii_whitespace().next()?.parse().ok()?;
76        match key {
77            "MemTotal" => total = Some(kib * 1024),
78            "MemAvailable" => avail = Some(kib * 1024),
79            _ => {}
80        }
81        if total.is_some() && avail.is_some() {
82            break;
83        }
84    }
85    Some((total?, avail?))
86}
87
88/// Whole seconds out of `/proc/uptime`.
89pub fn parse_uptime(text: &str) -> Option<u64> {
90    let first = text.split_ascii_whitespace().next()?;
91    let secs: f64 = first.parse().ok()?;
92    Some(secs as u64)
93}
94
95/// The 1-minute load out of `/proc/loadavg`, as printed.
96pub fn parse_loadavg(text: &str) -> Option<String> {
97    text.split_ascii_whitespace().next().map(str::to_owned)
98}
99
100/// The first `model name` out of `/proc/cpuinfo`, shortened: vendor noise
101/// (`(R)`, `(TM)`, `CPU @ 2.60GHz`, `Processor`) stripped, so it fits a cell.
102///
103/// `N-Core` / `N-Cores` goes too: it is the HOST chip's core count, and on a
104/// 4-vCPU cloud plan `4x AMD EPYC 7542 32-Core` read as two contradicting
105/// numbers (measured on UpCloud 2026-08-29). The vCPU count the band prints
106/// in front of the name is the one that is true of this box.
107pub fn parse_cpu_model(text: &str) -> Option<String> {
108    let line = text.lines().find(|l| l.starts_with("model name"))?;
109    let (_, value) = line.split_once(':')?;
110    let mut out = String::new();
111    for word in value.split_ascii_whitespace() {
112        let w = word
113            .replace("(R)", "")
114            .replace("(TM)", "")
115            .replace("(tm)", "");
116        if w.is_empty()
117            || w == "CPU"
118            || w == "Processor"
119            || w == "@"
120            || w.ends_with("GHz")
121            || is_core_count(&w)
122        {
123            continue;
124        }
125        if !out.is_empty() {
126            out.push(' ');
127        }
128        out.push_str(&w);
129    }
130    if out.is_empty() {
131        None
132    } else {
133        Some(out)
134    }
135}
136
137/// `32-Core` / `64-Cores`: the host chip's core count in a model name.
138fn is_core_count(word: &str) -> bool {
139    let Some(digits) = word
140        .strip_suffix("-Cores")
141        .or_else(|| word.strip_suffix("-Core"))
142    else {
143        return false;
144    };
145    !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
146}
147
148// ── what the data root IS: /proc/mounts + the md sysfs ──────────────────────
149
150/// The filesystem line of `/proc/mounts` for one mount point.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct MountFacts {
153    /// `/dev/md127`, `/dev/md/korp-data`, `/dev/vda3`, …
154    pub source: String,
155    /// `xfs`, `vfat`, …
156    pub fstype: String,
157}
158
159/// The LAST `/proc/mounts` line whose mount point is `mountpoint` (a later
160/// mount shadows an earlier one at the same path). Octal escapes in the path
161/// (`\040` for a space) are decoded so `/data` matches only `/data`.
162pub fn parse_mounts(text: &str, mountpoint: &str) -> Option<MountFacts> {
163    let mut found = None;
164    for line in text.lines() {
165        let mut f = line.split_ascii_whitespace();
166        let (Some(source), Some(target), Some(fstype)) = (f.next(), f.next(), f.next()) else {
167            continue;
168        };
169        if unescape_mount(target) == mountpoint {
170            found = Some(MountFacts {
171                source: unescape_mount(source),
172                fstype: fstype.to_owned(),
173            });
174        }
175    }
176    found
177}
178
179fn unescape_mount(s: &str) -> String {
180    let b = s.as_bytes();
181    let mut out = Vec::with_capacity(b.len());
182    let mut i = 0;
183    while i < b.len() {
184        if b[i] == b'\\'
185            && i + 3 < b.len()
186            && b[i + 1..i + 4].iter().all(|c| (b'0'..=b'7').contains(c))
187        {
188            let v = (b[i + 1] - b'0') * 64 + (b[i + 2] - b'0') * 8 + (b[i + 3] - b'0');
189            out.push(v);
190            i += 4;
191        } else {
192            out.push(b[i]);
193            i += 1;
194        }
195    }
196    String::from_utf8_lossy(&out).into_owned()
197}
198
199/// An md array's shape as its sysfs `md/` directory says it.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SetFacts {
202    /// `raid0`, `raid1`, …
203    pub level: String,
204    /// `md/raid_disks`.
205    pub members: usize,
206    /// The smallest member's share of the array, bytes (`md/dev-*/size` is
207    /// KiB); members are equal by korp-installer's rule, so this is the size
208    /// a human recognises as "the disk".
209    pub member_bytes: u64,
210    /// `md/chunk_size`, bytes.
211    pub chunk_bytes: u64,
212}
213
214/// Read one array off its sysfs block directory (`/sys/block/md127`,
215/// `/sys/dev/block/9:127`): `md/level`, `md/raid_disks`, `md/chunk_size`,
216/// and every `md/dev-*/size`. `None` unless all of level, count and at least
217/// one member size are there — a half-known set is printed as nothing, never
218/// as `0×0G` (the identity-value trap: measure, never assert).
219pub fn read_set_facts(block_dir: &std::path::Path) -> Option<SetFacts> {
220    let md = block_dir.join("md");
221    let text = |name: &str| {
222        std::fs::read_to_string(md.join(name))
223            .ok()
224            .map(|t| t.trim().to_owned())
225    };
226    let level = text("level").filter(|l| !l.is_empty())?;
227    let members: usize = text("raid_disks")?.parse().ok().filter(|&n| n > 0)?;
228    let chunk_bytes: u64 = text("chunk_size").and_then(|c| c.parse().ok()).unwrap_or(0);
229    let mut smallest: Option<u64> = None;
230    for entry in std::fs::read_dir(&md).ok()? {
231        let Ok(entry) = entry else { continue };
232        let name = entry.file_name();
233        let Some(dev) = name.to_str().and_then(|n| n.strip_prefix("dev-")) else {
234            continue;
235        };
236        let kib = std::fs::read_to_string(entry.path().join("size"))
237            .ok()
238            .and_then(|t| t.trim().parse::<u64>().ok())
239            .map(|k| k * 1024)
240            .or_else(|| {
241                // Fallback: the member's own block size, in 512-byte sectors.
242                std::fs::read_to_string(block_dir.join("../../class/block").join(dev).join("size"))
243                    .ok()
244                    .and_then(|t| t.trim().parse::<u64>().ok())
245                    .map(|s| s * 512)
246            });
247        if let Some(b) = kib.filter(|&b| b > 0) {
248            smallest = Some(smallest.map_or(b, |s| s.min(b)));
249        }
250    }
251    Some(SetFacts {
252        level,
253        members,
254        member_bytes: smallest?,
255        chunk_bytes,
256    })
257}
258
259/// What the data root IS, for the `disk` cell: the filesystem, and the array
260/// under it when the source is an md device.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct DiskKind {
263    /// `xfs`, `fat` (the screen's word for `vfat`/`msdos`), else as mounted.
264    pub fs: String,
265    pub set: Option<SetFacts>,
266}
267
268impl DiskKind {
269    pub fn from_mount(mount: &MountFacts, set: Option<SetFacts>) -> Self {
270        let fs = match mount.fstype.as_str() {
271            "vfat" | "msdos" | "fat" => "fat".to_owned(),
272            other => other.to_owned(),
273        };
274        DiskKind { fs, set }
275    }
276}
277
278impl std::fmt::Display for DiskKind {
279    /// `4×75G raid0 xfs` / `xfs` / `fat`.
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        if let Some(set) = &self.set {
282            write!(
283                f,
284                "{}\u{d7}{} {} ",
285                set.members,
286                crate::dashboard::fmt_bytes(set.member_bytes),
287                set.level
288            )?;
289        }
290        f.write_str(&self.fs)
291    }
292}
293
294/// The sysfs block directory of the device node at `source`, through
295/// `/sys/dev/block/<major>:<minor>` — the one door that works whatever the
296/// node is called (`/dev/md127`, `/dev/md/korp-data` mknod'ed by korp-start).
297fn sysfs_block_dir_of(source: &str) -> Option<std::path::PathBuf> {
298    use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
299    let meta = std::fs::metadata(source).ok()?;
300    if !meta.file_type().is_block_device() {
301        return None;
302    }
303    let rdev = meta.rdev();
304    let dir = std::path::PathBuf::from(format!(
305        "/sys/dev/block/{}:{}",
306        rustix::fs::major(rdev),
307        rustix::fs::minor(rdev)
308    ));
309    dir.is_dir().then_some(dir)
310}
311
312/// Live: `/proc/mounts` for `mountpoint`, then the md sysfs when the source
313/// is an array. `None` when the path is not a mount point of its own (a
314/// developer's `--data-root /tmp/x`), so the cell appends nothing.
315pub fn disk_kind(mountpoint: &std::path::Path) -> Option<DiskKind> {
316    let mount = parse_mounts(&read("/proc/mounts")?, mountpoint.to_str()?)?;
317    let set = sysfs_block_dir_of(&mount.source).and_then(|d| read_set_facts(&d));
318    Some(DiskKind::from_mount(&mount, set))
319}
320
321/// One address on one interface, as the screen prints it.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct IfAddr {
324    pub interface: String,
325    pub address: IpAddr,
326    pub prefix: u8,
327}
328
329impl std::fmt::Display for IfAddr {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        write!(f, "{} {}/{}", self.interface, self.address, self.prefix)
332    }
333}
334
335// ── netlink RTM_GETADDR, the dump ───────────────────────────────────────────
336
337const RTM_GETADDR: u16 = 22;
338const RTM_NEWADDR: u16 = 20;
339const NLMSG_DONE: u16 = 3;
340const NLMSG_ERROR: u16 = 2;
341const NLM_F_REQUEST: u16 = 0x0001;
342const NLM_F_DUMP: u16 = 0x0300;
343const NLM_F_MULTI: u16 = 0x0002;
344const AF_INET: u8 = 2;
345const AF_INET6: u8 = 10;
346const IFA_ADDRESS: u16 = 1;
347const IFA_LOCAL: u16 = 2;
348const IFA_LABEL: u16 = 3;
349/// `RT_SCOPE_HOST`: loopback and its kin — not something a client dials.
350const RT_SCOPE_HOST: u8 = 254;
351/// `RT_SCOPE_LINK`: `fe80::/10` — real, but not the address anyone types.
352const RT_SCOPE_LINK: u8 = 253;
353
354/// The `RTM_GETADDR` dump request for `family` (`AF_UNSPEC` = 0 for all).
355pub fn getaddr_dump_message(seq: u32) -> Vec<u8> {
356    // ifaddrmsg: family, prefixlen, flags, scope, index — 8 bytes, all zero
357    // for a dump.
358    netlink_message(RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, seq, &[0u8; 8])
359}
360
361/// Every `RTM_NEWADDR` in one or more dump answers → addresses worth showing:
362/// host scope (loopback) and link scope (`fe80::`) are left out. Interface
363/// names come from `IFA_LABEL` (IPv4 carries it) or from `index_to_name`
364/// through `resolve`, so this parser is pure over bytes.
365///
366/// Returns `(addresses, done)`; `done` is true once `NLMSG_DONE` was seen.
367pub fn parse_getaddr_reply(
368    buf: &[u8],
369    resolve: &mut dyn FnMut(u32) -> Option<String>,
370) -> (Vec<IfAddr>, bool) {
371    let mut out = Vec::new();
372    let mut off = 0usize;
373    let mut done = false;
374    while off + 16 <= buf.len() {
375        let len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) as usize;
376        let kind = u16::from_le_bytes([buf[off + 4], buf[off + 5]]);
377        let flags = u16::from_le_bytes([buf[off + 6], buf[off + 7]]);
378        if len < 16 || off + len > buf.len() {
379            break;
380        }
381        match kind {
382            NLMSG_DONE => {
383                done = true;
384                break;
385            }
386            NLMSG_ERROR => {
387                done = true;
388                break;
389            }
390            RTM_NEWADDR if len >= 24 => {
391                let body = &buf[off + 16..off + len];
392                let family = body[0];
393                let prefix = body[1];
394                let scope = body[3];
395                let index = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
396                if scope != RT_SCOPE_HOST && scope != RT_SCOPE_LINK {
397                    let mut address = None;
398                    let mut local = None;
399                    let mut label = None;
400                    let mut a = 8usize;
401                    while a + 4 <= body.len() {
402                        let alen = u16::from_le_bytes([body[a], body[a + 1]]) as usize;
403                        let atype = u16::from_le_bytes([body[a + 2], body[a + 3]]);
404                        if alen < 4 || a + alen > body.len() {
405                            break;
406                        }
407                        let payload = &body[a + 4..a + alen];
408                        match atype {
409                            IFA_ADDRESS => address = ip_from(family, payload),
410                            IFA_LOCAL => local = ip_from(family, payload),
411                            IFA_LABEL => {
412                                let end = payload
413                                    .iter()
414                                    .position(|&b| b == 0)
415                                    .unwrap_or(payload.len());
416                                label = String::from_utf8(payload[..end].to_vec()).ok();
417                            }
418                            _ => {}
419                        }
420                        a += align4(alen);
421                    }
422                    // IFA_LOCAL is the interface's own address on a
423                    // point-to-point link; IFA_ADDRESS is the peer there and
424                    // the same address everywhere else.
425                    if let Some(ip) = local.or(address) {
426                        let interface = label
427                            .or_else(|| resolve(index))
428                            .unwrap_or_else(|| format!("if{index}"));
429                        out.push(IfAddr {
430                            interface,
431                            address: ip,
432                            prefix,
433                        });
434                    }
435                }
436            }
437            _ => {}
438        }
439        if flags & NLM_F_MULTI == 0 {
440            // A single (non-multipart) answer ends here.
441            done = true;
442        }
443        off += align4(len);
444    }
445    (out, done)
446}
447
448fn ip_from(family: u8, payload: &[u8]) -> Option<IpAddr> {
449    match (family, payload.len()) {
450        (AF_INET, 4) => Some(IpAddr::V4(Ipv4Addr::new(
451            payload[0], payload[1], payload[2], payload[3],
452        ))),
453        (AF_INET6, 16) => {
454            let mut b = [0u8; 16];
455            b.copy_from_slice(payload);
456            Some(IpAddr::V6(Ipv6Addr::from(b)))
457        }
458        _ => None,
459    }
460}
461
462/// The live dump: one request, answers until `NLMSG_DONE`. Empty on any
463/// failure — the screen shows no addresses rather than a wrong one.
464pub fn addresses() -> Vec<IfAddr> {
465    use rustix::net::{
466        netdevice, recv, send, socket, AddressFamily, RecvFlags, SendFlags, SocketType,
467    };
468    let Ok(sock) = netlink_socket() else {
469        return Vec::new();
470    };
471    if send(&sock, &getaddr_dump_message(1), SendFlags::empty()).is_err() {
472        return Vec::new();
473    }
474    let probe = socket(AddressFamily::INET, SocketType::DGRAM, None).ok();
475    let mut resolve = |index: u32| -> Option<String> {
476        let probe = probe.as_ref()?;
477        netdevice::index_to_name(probe, index).ok()
478    };
479    let mut out = Vec::new();
480    let mut buf = vec![0u8; 16384];
481    // A dump is a bounded number of datagrams; 64 is far past any real box.
482    for _ in 0..64 {
483        let Ok((n, _)) = recv(&sock, &mut buf[..], RecvFlags::empty()) else {
484            break;
485        };
486        if n == 0 {
487            break;
488        }
489        let (mut got, done) = parse_getaddr_reply(&buf[..n], &mut resolve);
490        out.append(&mut got);
491        if done {
492            break;
493        }
494    }
495    out
496}
497
498// ── the readers ─────────────────────────────────────────────────────────────
499
500fn read(path: &str) -> Option<String> {
501    std::fs::read_to_string(path).ok()
502}
503
504pub fn hostname() -> Option<String> {
505    let h = read("/proc/sys/kernel/hostname")?;
506    let h = h.trim();
507    if h.is_empty() || h == "(none)" {
508        None
509    } else {
510        Some(h.to_owned())
511    }
512}
513
514pub fn cpu_ticks() -> (Option<CpuTicks>, usize) {
515    read("/proc/stat")
516        .map(|t| parse_stat(&t))
517        .unwrap_or((None, 0))
518}
519
520pub fn memory() -> Option<(u64, u64)> {
521    meminfo().ok()
522}
523
524/// `(MemTotal, MemAvailable)` in bytes, or **why not**. The band shows a dash
525/// for a missing number; `Admin.Capacity` must name the cause of an absence,
526/// so this is the reader both go through — [`memory`] is its `.ok()`.
527pub fn meminfo() -> Result<(u64, u64), String> {
528    let text = read_why("/proc/meminfo")?;
529    parse_meminfo(&text)
530        .ok_or_else(|| "/proc/meminfo has no MemTotal and MemAvailable lines to parse".to_owned())
531}
532
533/// The 1-minute load out of `/proc/loadavg` as a number, or why not.
534/// [`load1`] is the band's string of the same field.
535pub fn load1_value() -> Result<f64, String> {
536    let text = read_why("/proc/loadavg")?;
537    parse_loadavg(&text)
538        .and_then(|first| first.parse::<f64>().ok())
539        .ok_or_else(|| "/proc/loadavg does not start with a number".to_owned())
540}
541
542/// [`read`] that keeps the OS's reason, for the readers whose caller reports
543/// an absence rather than drawing a dash.
544fn read_why(path: &str) -> Result<String, String> {
545    std::fs::read_to_string(path).map_err(|err| format!("{path} could not be read ({err})"))
546}
547
548pub fn uptime_secs() -> Option<u64> {
549    parse_uptime(&read("/proc/uptime")?)
550}
551
552pub fn load1() -> Option<String> {
553    parse_loadavg(&read("/proc/loadavg")?)
554}
555
556pub fn cpu_model() -> Option<String> {
557    parse_cpu_model(&read("/proc/cpuinfo")?)
558}
559
560/// One live index row's facts, engine-neutral: the repository's git dir and
561/// the engine's snapshot. Rate is the screen's to derive across two ticks.
562#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct IndexFacts {
564    pub git_dir: std::path::PathBuf,
565    pub snap: crate::storage::IndexSnapshot,
566}
567
568/// Every open repository's index work, active or not. A few atomics per open
569/// store; no store lock, no `/proc`.
570pub fn index_progress(engine: crate::storage::Engine) -> Vec<IndexFacts> {
571    engine
572        .index_progress()
573        .into_iter()
574        .map(|(git_dir, snap)| IndexFacts { git_dir, snap })
575        .collect()
576}
577
578/// `(total, available)` bytes of the filesystem holding `path`.
579pub fn disk(path: &std::path::Path) -> Option<(u64, u64)> {
580    let st = rustix::fs::statfs(path).ok()?;
581    let bsize = st.f_frsize as u64;
582    Some((st.f_blocks * bsize, st.f_bavail * bsize))
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use appliance_pid1::net::rtattr;
589
590    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";
591
592    #[test]
593    fn stat_counts_cpus_and_turns_two_reads_into_a_busy_percent() {
594        let (t, cpus) = parse_stat(STAT);
595        assert_eq!(cpus, 2);
596        let t = t.unwrap();
597        assert_eq!(t.total, 4705 + 3540 + 178540 + 320 + 87);
598        assert_eq!(t.busy, 4705 + 3540 + 87);
599        let later = CpuTicks {
600            busy: t.busy + 50,
601            total: t.total + 200,
602        };
603        assert_eq!(later.busy_percent_since(t), Some(25));
604        assert_eq!(
605            t.busy_percent_since(t),
606            None,
607            "an empty interval is no percentage"
608        );
609        assert_eq!(
610            t.busy_percent_since(later),
611            None,
612            "a wrapped/earlier read is refused, not negative"
613        );
614    }
615
616    #[test]
617    fn meminfo_gives_total_and_available_in_bytes() {
618        let text = "MemTotal:       16303748 kB\nMemFree:         1234 kB\nMemAvailable:   15000000 kB\nBuffers: 1 kB\n";
619        assert_eq!(
620            parse_meminfo(text),
621            Some((16303748 * 1024, 15000000 * 1024))
622        );
623        assert_eq!(
624            parse_meminfo("MemTotal: 1 kB\n"),
625            None,
626            "a file without MemAvailable is not guessed at"
627        );
628    }
629
630    #[test]
631    fn uptime_load_and_model_parse_the_real_shapes() {
632        assert_eq!(parse_uptime("350735.47 234388.90\n"), Some(350735));
633        assert_eq!(
634            parse_loadavg("0.08 0.03 0.01 1/412 12345\n"),
635            Some("0.08".into())
636        );
637        assert_eq!(parse_cpu_model("processor: 0\n"), None);
638    }
639
640    /// The model cell: what `/proc/cpuinfo` says → what the band prints.
641    /// The host chip's `N-Core` is not this box's CPU count (a 4-vCPU plan
642    /// on an EPYC 7542 said `4x … 32-Core` on UpCloud, 2026-08-29).
643    #[test]
644    fn the_cpu_model_is_trimmed_of_suffixes_that_are_not_the_chip_name() {
645        let table = [
646            ("AMD EPYC 7542 32-Core Processor", "AMD EPYC 7542"),
647            (
648                "AMD Ryzen Threadripper PRO 3975WX 32-Cores",
649                "AMD Ryzen Threadripper PRO 3975WX",
650            ),
651            (
652                "Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
653                "Intel Xeon E5-2680 v4",
654            ),
655            (
656                "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
657                "Intel Core i7-8650U",
658            ),
659            (
660                "Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
661                "Intel Xeon Platinum 8375C",
662            ),
663            ("QEMU Virtual CPU version 2.5+", "QEMU Virtual version 2.5+"),
664            ("ARM Cortex-A72", "ARM Cortex-A72"),
665        ];
666        for (raw, want) in table {
667            let cpuinfo = format!("processor\t: 0\nvendor_id\t: x\nmodel name\t: {raw}\n");
668            assert_eq!(parse_cpu_model(&cpuinfo).as_deref(), Some(want), "{raw}");
669        }
670        assert!(
671            is_core_count("32-Core")
672                && is_core_count("64-Cores")
673                && !is_core_count("-Core")
674                && !is_core_count("Cortex-A72")
675        );
676    }
677
678    /// One RTM_NEWADDR with IFA_ADDRESS + IFA_LABEL, in a multipart answer
679    /// followed by DONE.
680    fn newaddr(
681        family: u8,
682        prefix: u8,
683        scope: u8,
684        index: u32,
685        addr: &[u8],
686        label: Option<&str>,
687    ) -> Vec<u8> {
688        let mut body = vec![family, prefix, 0, scope];
689        body.extend_from_slice(&index.to_le_bytes());
690        body.extend_from_slice(&rtattr(IFA_ADDRESS, addr));
691        if let Some(l) = label {
692            let mut z = l.as_bytes().to_vec();
693            z.push(0);
694            body.extend_from_slice(&rtattr(IFA_LABEL, &z));
695        }
696        netlink_message(RTM_NEWADDR, NLM_F_MULTI, 1, &body)
697    }
698
699    fn done() -> Vec<u8> {
700        netlink_message(NLMSG_DONE, NLM_F_MULTI, 1, &[0u8; 4])
701    }
702
703    #[test]
704    fn the_dump_request_is_a_request_plus_dump_over_an_empty_ifaddrmsg() {
705        let m = getaddr_dump_message(7);
706        assert_eq!(m.len(), 24);
707        assert_eq!(u16::from_le_bytes([m[4], m[5]]), RTM_GETADDR);
708        assert_eq!(u16::from_le_bytes([m[6], m[7]]), NLM_F_REQUEST | NLM_F_DUMP);
709        assert_eq!(&m[16..], &[0u8; 8]);
710    }
711
712    #[test]
713    fn a_dump_reply_yields_the_dialable_addresses_and_skips_loopback_and_link_local() {
714        let mut buf = Vec::new();
715        buf.extend(newaddr(
716            AF_INET,
717            8,
718            RT_SCOPE_HOST,
719            1,
720            &[127, 0, 0, 1],
721            Some("lo"),
722        ));
723        buf.extend(newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0")));
724        buf.extend(newaddr(
725            AF_INET6,
726            64,
727            RT_SCOPE_LINK,
728            2,
729            &[0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
730            None,
731        ));
732        buf.extend(newaddr(
733            AF_INET6,
734            64,
735            0,
736            2,
737            &[0x20, 0x01, 0xd, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
738            None,
739        ));
740        buf.extend(done());
741        let mut resolved = Vec::new();
742        let (addrs, finished) = parse_getaddr_reply(&buf, &mut |i| {
743            resolved.push(i);
744            Some(format!("if{i}-resolved"))
745        });
746        assert!(finished);
747        assert_eq!(
748            addrs.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
749            ["eth0 10.0.2.15/24", "if2-resolved 2001:db8::1/64"]
750        );
751        assert_eq!(
752            resolved,
753            [2],
754            "only the label-less address asked for a name"
755        );
756    }
757
758    #[test]
759    fn a_reply_without_done_says_so_and_a_truncated_one_stops_cleanly() {
760        let full = newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0"));
761        let (addrs, finished) = parse_getaddr_reply(&full, &mut |_| None);
762        assert_eq!(addrs.len(), 1);
763        assert!(!finished, "multipart without DONE: more datagrams follow");
764        let (addrs, _) = parse_getaddr_reply(&full[..full.len() - 3], &mut |_| None);
765        assert!(
766            addrs.is_empty(),
767            "a message whose declared length overruns the buffer is refused"
768        );
769    }
770
771    #[test]
772    fn the_live_readers_answer_on_this_box() {
773        // Not a fixture: these read the running kernel. The assertion is on
774        // shape — a Linux box has ≥1 CPU, RAM, and an uptime.
775        let (ticks, cpus) = cpu_ticks();
776        assert!(ticks.is_some() && cpus >= 1);
777        let (total, avail) = memory().expect("meminfo");
778        assert!(total >= avail && total > 0);
779        assert!(uptime_secs().is_some());
780        let (dt, da) = disk(std::path::Path::new("/")).expect("statfs /");
781        assert!(dt >= da && dt > 0);
782    }
783}
784
785// ── PCI hot-plug: can a member leave and come back while gunnar runs? ──────
786
787/// **Does the RUNNING kernel have PCI hot-plug?** Measured, never declared.
788///
789/// Why it matters: UpCloud resizes only DETACHED volumes, so a runtime growth
790/// is detach → resize → re-attach, and a kernel with `# CONFIG_HOTPLUG_PCI is
791/// not set` never sees the member leave or come back (`511 HOTPLUG_FAILED`).
792///
793/// The rule, in order — both work on a no-OS PID 1 with no shell and a kernel
794/// built with `CONFIG_MODULES=n`:
795///
796/// 1. `/proc/config.gz` (only when the kernel has `CONFIG_IKCONFIG_PROC`;
797///    `tunnr-kernel`'s `nuked` preset does NOT): `CONFIG_HOTPLUG_PCI=y` AND at
798///    least one controller (`_ACPI`, `_PCIE`, `_SHPC`) `=y`.
799/// 2. Otherwise the built-in controllers' parameter directories:
800///    `/sys/module/acpiphp` (`acpiphp.disable`), `/sys/module/pciehp`
801///    (`pciehp.pciehp_poll_mode`), `/sys/module/shpchp` (`shpchp.shpchp_debug`).
802///    `param_sysfs_builtin` creates these for built-in code with parameters
803///    whether or not modules exist; nothing else in the tree creates them.
804///
805/// NOT used: `/sys/bus/pci/slots` — `drivers/pci/slot.o` builds on `SYSFS`
806/// alone, so the directory exists on a kernel with no hot-plug at all.
807#[derive(Debug, Clone, PartialEq, Eq)]
808pub struct PciHotplug {
809    pub present: bool,
810    /// What was read, for the reason line: `config.gz: …` or `sys/module: …`.
811    pub evidence: String,
812}
813
814/// The built-in hot-plug controllers whose `/sys/module/<name>` directory is
815/// the fallback evidence.
816pub const HOTPLUG_CONTROLLERS: [&str; 3] = ["acpiphp", "pciehp", "shpchp"];
817
818/// Pure over what was read: the decompressed `/proc/config.gz` (if any) and a
819/// predicate for `/sys/module/<name>` existing.
820pub fn decide_pci_hotplug(config: Option<&str>, has_module: &dyn Fn(&str) -> bool) -> PciHotplug {
821    if let Some(text) = config {
822        let y = |sym: &str| text.lines().any(|l| l.trim() == format!("{sym}=y"));
823        let controllers: Vec<&str> = [
824            "CONFIG_HOTPLUG_PCI_ACPI",
825            "CONFIG_HOTPLUG_PCI_PCIE",
826            "CONFIG_HOTPLUG_PCI_SHPC",
827        ]
828        .into_iter()
829        .filter(|s| y(s))
830        .collect();
831        let present = y("CONFIG_HOTPLUG_PCI") && !controllers.is_empty();
832        return PciHotplug {
833            present,
834            evidence: if present {
835                format!("config.gz: CONFIG_HOTPLUG_PCI=y, {}", controllers.join(" "))
836            } else {
837                "config.gz: CONFIG_HOTPLUG_PCI or every controller is not set".to_owned()
838            },
839        };
840    }
841    let found: Vec<&str> = HOTPLUG_CONTROLLERS
842        .into_iter()
843        .filter(|m| has_module(m))
844        .collect();
845    PciHotplug {
846        present: !found.is_empty(),
847        evidence: if found.is_empty() {
848            "sys/module: no acpiphp, pciehp or shpchp (no /proc/config.gz)".to_owned()
849        } else {
850            format!("sys/module: {} (no /proc/config.gz)", found.join(" "))
851        },
852    }
853}
854
855/// Read it off the running machine.
856pub fn pci_hotplug() -> PciHotplug {
857    use std::io::Read as _;
858    let config = std::fs::File::open("/proc/config.gz").ok().and_then(|f| {
859        let mut s = String::new();
860        flate2::read::GzDecoder::new(f)
861            .read_to_string(&mut s)
862            .ok()
863            .map(|_| s)
864    });
865    decide_pci_hotplug(config.as_deref(), &|m| {
866        std::path::Path::new("/sys/module").join(m).is_dir()
867    })
868}
869
870#[cfg(test)]
871mod pci_hotplug_tests {
872    use super::*;
873
874    #[test]
875    fn config_gz_decides_when_present_and_needs_a_controller() {
876        let none = |_: &str| true; // the fallback must not be consulted
877        let hot = "CONFIG_PCI=y\nCONFIG_HOTPLUG_PCI=y\nCONFIG_HOTPLUG_PCI_ACPI=y\n";
878        let p = decide_pci_hotplug(Some(hot), &none);
879        assert!(
880            p.present && p.evidence.contains("HOTPLUG_PCI_ACPI"),
881            "{p:?}"
882        );
883        assert!(
884            !decide_pci_hotplug(Some("CONFIG_HOTPLUG_PCI=y\n"), &none).present,
885            "the core without a controller acknowledges nothing"
886        );
887        assert!(
888            !decide_pci_hotplug(
889                Some("# CONFIG_HOTPLUG_PCI is not set\nCONFIG_HOTPLUG_PCI_ACPI=y\n"),
890                &none
891            )
892            .present
893        );
894    }
895
896    #[test]
897    fn without_config_gz_the_builtin_controllers_decide() {
898        let p = decide_pci_hotplug(None, &|m| m == "pciehp" || m == "acpiphp");
899        assert!(
900            p.present && p.evidence == "sys/module: acpiphp pciehp (no /proc/config.gz)",
901            "{p:?}"
902        );
903        let p = decide_pci_hotplug(None, &|_| false);
904        assert!(!p.present && p.evidence.contains("no acpiphp"), "{p:?}");
905    }
906}