crates/appliance-grow/src/probe.rs
//! **The two machine facts the growth decides over**, read the cheap way —
//! moved AS IS out of gunnar-server's `dashboard/probe.rs` (gunnar 18a44e3):
//!
//! * [`disk_kind`] — what the data root IS: `/proc/mounts` for the mount, and
//! the md sysfs under it when the source is an array (`4×75G raid0 xfs`);
//! * [`pci_hotplug`] — whether the RUNNING kernel can see a member leave and
//! come back (`/proc/config.gz`, else the built-in controllers under
//! `/sys/module`).
//!
//! Every reader answers `None`/`false` with the evidence named rather than
//! failing, and every parser is pure over what was read, so the tests feed
//! recorded text and assert on the parsed value.
/// 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: 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, fmt_bytes(set.member_bytes), set.level)?;
}
f.write_str(&self.fs)
}
}
/// `75G`, `1.5T`, `512B` — the header band's spelling (1024-based, 3 digits).
pub fn fmt_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut v = n as f64;
let mut u = 0;
while v >= 1000.0 && u < UNITS.len() - 1 {
v /= 1024.0;
u += 1;
}
if u == 0 {
format!("{n}B")
} else if v < 10.0 {
format!("{v:.1}{}", UNITS[u])
} else {
format!("{v:.0}{}", UNITS[u])
}
}
/// 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`) — the honest UNKNOWN.
pub fn disk_kind(mountpoint: &std::path::Path) -> Option<DiskKind> {
let mount = parse_mounts(&std::fs::read_to_string("/proc/mounts").ok()?, mountpoint.to_str()?)?;
let set = sysfs_block_dir_of(&mount.source).and_then(|d| read_set_facts(&d));
Some(DiskKind::from_mount(&mount, set))
}
/// **Does the running kernel have PCI hot-plug?** — the owner's rule
/// (2026-09-15): growth WITHOUT a restart needs a kernel that sees a member
/// detached, resized and re-attached (UpCloud resizes only detached volumes).
///
/// 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 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:?}");
}
/// The last line wins (a later mount shadows), escapes decode, and an md
/// set's sysfs reads as `4×75G raid0 xfs`.
#[test]
fn the_data_root_reads_as_its_mount_and_its_set() {
let mounts = "/dev/vda3 /data ext4 rw 0 0\n/dev/md/korp-data /data xfs rw 0 0\nnone /my\\040dir tmpfs rw 0 0\n";
assert_eq!(parse_mounts(mounts, "/data"), Some(MountFacts { source: "/dev/md/korp-data".into(), fstype: "xfs".into() }));
assert_eq!(parse_mounts(mounts, "/my dir").map(|m| m.fstype), Some("tmpfs".into()));
assert_eq!(parse_mounts(mounts, "/nope"), None);
let d = tempfile::tempdir().unwrap();
let md = d.path().join("md");
std::fs::create_dir_all(&md).unwrap();
std::fs::write(md.join("level"), "raid0\n").unwrap();
std::fs::write(md.join("raid_disks"), "4\n").unwrap();
std::fs::write(md.join("chunk_size"), "524288\n").unwrap();
for i in 0..4 {
let dev = md.join(format!("dev-vd{}1", (b'b' + i) as char));
std::fs::create_dir_all(&dev).unwrap();
std::fs::write(dev.join("size"), format!("{}\n", 75u64 << 20)).unwrap();
}
let set = read_set_facts(d.path()).expect("a whole set");
let kind = DiskKind::from_mount(&MountFacts { source: "/dev/md127".into(), fstype: "xfs".into() }, Some(set));
assert_eq!(kind.to_string(), "4\u{d7}75G raid0 xfs");
assert_eq!(DiskKind::from_mount(&MountFacts { source: "x".into(), fstype: "vfat".into() }, None).to_string(), "fat");
}
}