479 lines
18 KiB
Rust
479 lines
18 KiB
Rust
//! Host-level fixups: OS packages, kernel parameters and system services the
|
||
//! node needs, delivered by the same signed-binary OTA that ships everything
|
||
//! else (docs/system-level-ota-design.md).
|
||
//!
|
||
//! Scope and posture — read before adding anything here:
|
||
//!
|
||
//! * **Idempotent + non-fatal.** Every step is a no-op when the host already
|
||
//! has the desired state, and a failure (offline box, locked dpkg, missing
|
||
//! package in the release's Debian suite) logs a warning and moves on. A
|
||
//! host fixup must never be able to stop the node from starting.
|
||
//! * **Curated, pinned intent — not dist-upgrade automation.** We deliver the
|
||
//! specific packages and settings a release deliberately adds (crash
|
||
//! capture, hardware-error logging, later: unattended-upgrades posture, host
|
||
//! firewall). Regular Debian upgrades stay with the operator; this channel
|
||
//! never silently swaps a kernel or a libc.
|
||
//! * **Fresh installs converge too.** The ISO bakes the same end state in
|
||
//! (Dockerfile.rootfs, auto-install.sh cmdline), so the fixup is a no-op on
|
||
//! new machines and only does real work on already-deployed nodes.
|
||
//! * **Kernel cmdline can't move at runtime.** `crashkernel=` reserves memory
|
||
//! at boot; the fixup writes GRUB and update-grub so the change lands on the
|
||
//! next reboot, and says so in the log. Everything else (packages, sysctls,
|
||
//! services) applies immediately.
|
||
//!
|
||
//! First payload (#144, docs/kdump-rasdaemon-design.md): kdump + rasdaemon —
|
||
//! post-mortem and hardware-error capture:
|
||
//! * kdump-tools/kexec-tools/rasdaemon installed
|
||
//! * /etc/default/kdump-tools: USE_KDUMP=1, dumps to /var/crash, compressed
|
||
//! core collector
|
||
//! * /etc/sysctl.d/99-archipelago-kdump.conf: a wedged node dumps and
|
||
//! reboots rather than sitting dead until power-cycled
|
||
//! * crashkernel=256M appended to the installed GRUB cmdline (next reboot)
|
||
//! * /var/crash pruned to the two newest dumps
|
||
//!
|
||
//! The module is skipped on dev boxes (same guard bootstrap::run uses) and on
|
||
//! hosts without dpkg.
|
||
|
||
use anyhow::{Context, Result};
|
||
use tracing::{debug, info, warn};
|
||
|
||
use crate::update::host_sudo;
|
||
|
||
/// Packages the node's host must have. Keep this list short and justified —
|
||
/// every entry is state we now own on the fleet's OS images.
|
||
const HOST_PACKAGES: &[&str] = &["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"];
|
||
|
||
/// Crash-kernel reservation. 256M covers the capture kernel plus makedumpfile
|
||
/// on the fleet's 16–64GB amd64 machines (~1–2% of RAM, permanently reserved).
|
||
/// The arm image (RPi) is out of scope for phase 1 — see the design doc.
|
||
const CRASHKERNEL_PARAM: &str = "crashkernel=256M";
|
||
|
||
const KDUMP_SYSDROPIN_PATH: &str = "/etc/sysctl.d/99-archipelago-kdump.conf";
|
||
const KDUMP_SYSDROPIN: &str = "\
|
||
# Archipelago kdump policy (#144). A wedged kiosk is useless until someone
|
||
# power-cycles it — capture the evidence, then reboot by itself. Dumps land in
|
||
# /var/crash (see docs/kdump-rasdaemon-design.md); keep-2 pruning is done by
|
||
# the host fixup pass, not a timer.
|
||
kernel.panic = 10
|
||
kernel.panic_on_oops = 1
|
||
kernel.hung_task_panic = 1
|
||
kernel.hardlockup_panic = 1
|
||
";
|
||
|
||
/// How many dumps to keep in /var/crash. Two ≈ 4 GiB worst case on the 30 GiB
|
||
/// unencrypted root — the partition usage itself is tracked by disk_monitor.
|
||
const KEEP_DUMPS: usize = 2;
|
||
|
||
/// Entry point, spawned from main.rs at startup like the other ensure_* heals.
|
||
pub async fn ensure_host_fixups() {
|
||
// Dev-box guard (same rationale as bootstrap::run): on contributor
|
||
// machines /home/archipelago/archy is a symlink into a git checkout and
|
||
// the host is the contributor's own OS — never touch it.
|
||
let home_archy = std::path::Path::new("/home/archipelago/archy");
|
||
if tokio::fs::symlink_metadata(home_archy)
|
||
.await
|
||
.map(|m| m.file_type().is_symlink())
|
||
.unwrap_or(false)
|
||
{
|
||
debug!("/home/archipelago/archy is a symlink — skipping host fixups (dev box)");
|
||
return;
|
||
}
|
||
// Non-Debian hosts: nothing we manage here applies.
|
||
if tokio::fs::symlink_metadata("/usr/bin/dpkg").await.is_err() {
|
||
debug!("no dpkg on this host — skipping host fixups");
|
||
return;
|
||
}
|
||
|
||
if let Err(e) = run_host_fixups().await {
|
||
warn!("host fixups failed (non-fatal): {:#}", e);
|
||
}
|
||
}
|
||
|
||
async fn run_host_fixups() -> Result<()> {
|
||
// 1. Packages — install only what's missing; a locked/offline apt must
|
||
// never block anything downstream (steps below degrade to no-ops).
|
||
match ensure_packages().await {
|
||
Ok(true) => info!("host fixups: installed missing packages"),
|
||
Ok(false) => debug!("host fixups: all packages present"),
|
||
Err(e) => warn!("host fixups: package install failed (non-fatal): {:#}", e),
|
||
}
|
||
|
||
// 2. kdump config + sysctl drop-in + GRUB cmdline + services. One helper
|
||
// per concern so a failure in one logs and leaves the others running.
|
||
if let Err(e) = ensure_kdump_sysdropin().await {
|
||
warn!(
|
||
"host fixups: kdump sysctl drop-in failed (non-fatal): {:#}",
|
||
e
|
||
);
|
||
}
|
||
if let Err(e) = ensure_kdump_defaults().await {
|
||
warn!(
|
||
"host fixups: kdump-tools config failed (non-fatal): {:#}",
|
||
e
|
||
);
|
||
}
|
||
match ensure_crashkernel_cmdline().await? {
|
||
true => {
|
||
warn!("host fixups: crashkernel= written to GRUB — takes effect on the NEXT reboot")
|
||
}
|
||
false => debug!("host fixups: crashkernel already in GRUB cmdline"),
|
||
}
|
||
if let Err(e) = ensure_rasdaemon_enabled().await {
|
||
warn!("host fixups: rasdaemon enable failed (non-fatal): {:#}", e);
|
||
}
|
||
if let Err(e) = prune_crash_dumps().await {
|
||
debug!("host fixups: /var/crash prune skipped: {:#}", e);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// True if any package was installed. Mirrors the polkit repair's apt posture:
|
||
/// install without `apt-get update` first; only if that fails (fresh suite,
|
||
/// stale index), update once and retry. Both under timeout, both non-fatal.
|
||
async fn ensure_packages() -> Result<bool> {
|
||
// Package names are a fixed internal allowlist. Do not embed shell quote
|
||
// characters in WANTED: quotes produced by variable expansion are data,
|
||
// so dpkg-query would look for a package literally named 'kdump-tools'.
|
||
let wanted = HOST_PACKAGES.join(" ");
|
||
let script = format!(
|
||
r#"
|
||
set -u
|
||
WANTED="{wanted}"
|
||
MISSING=""
|
||
for p in $WANTED; do
|
||
dpkg-query -W -f='${{Status}}' "$p" 2>/dev/null | grep -q 'install ok installed' || MISSING="$MISSING $p"
|
||
done
|
||
[ -z "$MISSING" ] && exit 0
|
||
timeout 240 apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1 \
|
||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends $MISSING >/dev/null 2>&1' \
|
||
|| exit 3
|
||
exit 2
|
||
"#
|
||
);
|
||
let status = host_sudo(&["sh", "-lc", &script])
|
||
.await
|
||
.context("install host packages")?;
|
||
match status.code() {
|
||
Some(0) => Ok(false),
|
||
Some(2) => Ok(true),
|
||
code => anyhow::bail!("host package install exited with {code:?}"),
|
||
}
|
||
}
|
||
|
||
/// Write the sysctl drop-in and apply it live (these four keys are all
|
||
/// runtime-settable, so the hang/panic policy takes effect without a reboot).
|
||
async fn ensure_kdump_sysdropin() -> Result<()> {
|
||
let script = format!(
|
||
r#"
|
||
set -u
|
||
PATH_FILE='{KDUMP_SYSDROPIN_PATH}'
|
||
CONTENT_FILE=/tmp/archy-kdump-sysctl.$$.tmp
|
||
cat > "$CONTENT_FILE" <<'SYSEOF'
|
||
{KDUMP_SYSDROPIN}SYSEOF
|
||
if [ -f "$PATH_FILE" ] && cmp -s "$CONTENT_FILE" "$PATH_FILE"; then
|
||
rm -f "$CONTENT_FILE"
|
||
exit 0
|
||
fi
|
||
mv "$CONTENT_FILE" "$PATH_FILE"
|
||
chmod 644 "$PATH_FILE"
|
||
sysctl --system >/dev/null 2>&1 || true
|
||
exit 2
|
||
"#
|
||
);
|
||
let status = host_sudo(&["sh", "-lc", &script])
|
||
.await
|
||
.context("write kdump sysctl drop-in")?;
|
||
match status.code() {
|
||
Some(0) => Ok(()),
|
||
Some(2) => {
|
||
info!("host fixups: installed {KDUMP_SYSDROPIN_PATH} (hang/panic policy)");
|
||
Ok(())
|
||
}
|
||
code => anyhow::bail!("kdump sysctl drop-in exited with {code:?}"),
|
||
}
|
||
}
|
||
|
||
/// Point kdump-tools at /var/crash with a compressed core collector. Works on
|
||
/// the package's shipped defaults file (USE_KDUMP=0, commented KDUMP_COREDIR)
|
||
/// and on any state we already wrote — pure line surgery, idempotent.
|
||
fn kdump_defaults_script(conf: &str) -> String {
|
||
r#"
|
||
set -u
|
||
CONF='@@CONF@@'
|
||
[ -f "$CONF" ] || exit 3
|
||
CHANGED=0
|
||
# Remove the one malformed line emitted by the old systemd-run environment
|
||
# expansion bug before it was disabled. It makes every kdump-config invocation
|
||
# print an error while sourcing this file.
|
||
if grep -Fqx '=""' "$CONF"; then
|
||
sed -i '/^=""$/d' "$CONF"
|
||
CHANGED=1
|
||
fi
|
||
set_kv() {
|
||
# Canonicalise KEY to one double-quoted assignment. Older fixup versions
|
||
# could append duplicates because their exact-value check did not accept
|
||
# double quotes; collapsing them also makes future passes idempotent.
|
||
KEY="$1"; VAL="$2"
|
||
EXPECTED="${KEY}=\"${VAL}\""
|
||
COUNT=$(grep -c "^${KEY}=" "$CONF" 2>/dev/null || true)
|
||
if [ "$COUNT" -eq 1 ] && grep -Fqx "$EXPECTED" "$CONF"; then
|
||
return
|
||
fi
|
||
sed -i "/^${KEY}=/d" "$CONF"
|
||
printf '\n%s\n' "$EXPECTED" >> "$CONF"
|
||
CHANGED=1
|
||
}
|
||
set_kv USE_KDUMP 1
|
||
set_kv KDUMP_COREDIR /var/crash
|
||
set_kv CORE_COLLECTOR 'makedumpfile -l --message-level 1 -d 31'
|
||
[ "$CHANGED" -eq 1 ] || exit 0
|
||
systemctl enable kdump-tools >/dev/null 2>&1 || true
|
||
exit 2
|
||
"#
|
||
.replace("@@CONF@@", conf)
|
||
}
|
||
|
||
async fn ensure_kdump_defaults() -> Result<()> {
|
||
let script = kdump_defaults_script("/etc/default/kdump-tools");
|
||
let status = host_sudo(&["sh", "-lc", &script])
|
||
.await
|
||
.context("configure kdump-tools")?;
|
||
match status.code() {
|
||
Some(0) => Ok(()),
|
||
Some(2) => {
|
||
info!("host fixups: kdump-tools configured (USE_KDUMP=1, /var/crash)");
|
||
Ok(())
|
||
}
|
||
code => anyhow::bail!("kdump-tools config exited with {code:?}"),
|
||
}
|
||
}
|
||
|
||
/// Set the installed GRUB cmdline to one fixed `crashkernel=` reservation and
|
||
/// run update-grub. Debian's kdump-tools package installs a grub.d snippet that
|
||
/// otherwise appends its own range-based reservation after ours; on amd64 that
|
||
/// silently wins and reserves only 192M instead of the intended 256M.
|
||
/// The reservation itself only exists after the next reboot — memory cannot
|
||
/// be set aside at runtime — so the caller must log the reboot caveat.
|
||
/// Returns true if the generated cmdline changed.
|
||
async fn ensure_crashkernel_cmdline() -> Result<bool> {
|
||
let script = format!(
|
||
r#"
|
||
set -u
|
||
GRUB=/etc/default/grub
|
||
KDUMP_GRUB=/etc/default/grub.d/kdump-tools.cfg
|
||
PARAM='{CRASHKERNEL_PARAM}'
|
||
[ -f "$GRUB" ] || exit 3
|
||
CHANGED=0
|
||
# kdump-tools sources this after /etc/default/grub and unconditionally appends
|
||
# crashkernel=512M-:192M. Neutralize that package default: Archipelago owns the
|
||
# explicit fixed reservation in GRUB_CMDLINE_LINUX_DEFAULT below.
|
||
if [ -f "$KDUMP_GRUB" ] && grep -qE '^[^#]*crashkernel=' "$KDUMP_GRUB"; then
|
||
printf '%s\n' '# Archipelago owns crashkernel sizing in /etc/default/grub.' > "$KDUMP_GRUB"
|
||
CHANGED=1
|
||
fi
|
||
LINE=$(grep -E '^GRUB_CMDLINE_LINUX_DEFAULT=' "$GRUB" | head -1)
|
||
[ -n "$LINE" ] || exit 3
|
||
# Remove any prior value before appending ours, so repeated fixups can never
|
||
# create conflicting parameters whose kernel precedence is easy to misread.
|
||
NEWLINE=$(printf '%s' "$LINE" | sed -E "s/[[:space:]]+crashkernel=[^ \"']+//g; s/\"$/ $PARAM\"/")
|
||
if [ "$NEWLINE" != "$LINE" ]; then
|
||
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|$NEWLINE|" "$GRUB"
|
||
CHANGED=1
|
||
fi
|
||
[ "$CHANGED" -eq 1 ] || exit 0
|
||
timeout 120 update-grub >/dev/null 2>&1 || true
|
||
exit 2
|
||
"#
|
||
);
|
||
let status = host_sudo(&["sh", "-lc", &script])
|
||
.await
|
||
.context("set crashkernel= in GRUB")?;
|
||
match status.code() {
|
||
Some(0) => Ok(false),
|
||
Some(2) => Ok(true),
|
||
code => anyhow::bail!("crashkernel cmdline fixup exited with {code:?}"),
|
||
}
|
||
}
|
||
|
||
async fn ensure_rasdaemon_enabled() -> Result<()> {
|
||
let status = host_sudo(&["systemctl", "enable", "--now", "rasdaemon"])
|
||
.await
|
||
.context("enable rasdaemon")?;
|
||
if status.success() {
|
||
Ok(())
|
||
} else {
|
||
anyhow::bail!("systemctl enable --now rasdaemon exited with {status}")
|
||
}
|
||
}
|
||
|
||
/// Keep only the newest [`KEEP_DUMPS`] dumps in /var/crash. Called on every
|
||
/// fixup pass rather than by a timer: the pass runs at every startup, which is
|
||
/// exactly the cadence at which new dumps appear (a dump ends in a reboot).
|
||
fn crash_dump_prune_script() -> String {
|
||
format!(
|
||
r#"
|
||
set -u
|
||
DIR=${{ARCHIPELAGO_CRASH_DIR:-/var/crash}}
|
||
[ -d "$DIR" ] || exit 0
|
||
KEEP={KEEP_DUMPS}
|
||
# kdump-tools keeps its lock and kexec command files beside timestamped dump
|
||
# directories. Count and prune directories only: treating those bookkeeping
|
||
# files as dumps can delete the sole freshly captured vmcore on startup.
|
||
COUNT=$(find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf . | wc -c)
|
||
[ "$COUNT" -gt "$KEEP" ] || exit 0
|
||
find "$DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\0' \
|
||
| sort -zrn \
|
||
| tail -z -n +"$((KEEP + 1))" \
|
||
| cut -z -d ' ' -f 2- \
|
||
| xargs -0r rm -rf --
|
||
exit 2
|
||
"#
|
||
)
|
||
}
|
||
|
||
async fn prune_crash_dumps() -> Result<()> {
|
||
let script = crash_dump_prune_script();
|
||
let status = host_sudo(&["sh", "-lc", &script])
|
||
.await
|
||
.context("prune /var/crash")?;
|
||
match status.code() {
|
||
Some(0) => Ok(()),
|
||
Some(2) => {
|
||
info!("host fixups: pruned old dumps in /var/crash (keep {KEEP_DUMPS})");
|
||
Ok(())
|
||
}
|
||
code => anyhow::bail!("/var/crash prune exited with {code:?}"),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn sysctl_dropin_carries_the_full_hang_capture_policy() {
|
||
for key in [
|
||
"kernel.panic = 10",
|
||
"kernel.panic_on_oops = 1",
|
||
"kernel.hung_task_panic = 1",
|
||
"kernel.hardlockup_panic = 1",
|
||
] {
|
||
assert!(KDUMP_SYSDROPIN.contains(key), "drop-in missing {key}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn package_list_is_exactly_the_kdump_rasdaemon_set() {
|
||
assert_eq!(
|
||
HOST_PACKAGES,
|
||
&["kdump-tools", "kexec-tools", "makedumpfile", "rasdaemon"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn crashkernel_param_is_sized_and_unprefixed() {
|
||
assert_eq!(CRASHKERNEL_PARAM, "crashkernel=256M");
|
||
}
|
||
|
||
#[test]
|
||
fn keep_dumps_is_two() {
|
||
assert_eq!(KEEP_DUMPS, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn kdump_defaults_repairs_old_malformed_line_and_is_idempotent() {
|
||
use std::{fs, process::Command};
|
||
|
||
let root = tempfile::tempdir().unwrap();
|
||
let conf = root.path().join("kdump-tools");
|
||
let bin = root.path().join("bin");
|
||
fs::create_dir(&bin).unwrap();
|
||
fs::write(bin.join("systemctl"), "#!/bin/sh\nexit 0\n").unwrap();
|
||
assert!(Command::new("chmod")
|
||
.args(["+x"])
|
||
.arg(bin.join("systemctl"))
|
||
.status()
|
||
.unwrap()
|
||
.success());
|
||
fs::write(
|
||
&conf,
|
||
"# package defaults\n=\"\"\nUSE_KDUMP=0\nUSE_KDUMP=\"1\"\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let script = kdump_defaults_script(conf.to_str().unwrap());
|
||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
|
||
let first = Command::new("sh")
|
||
.args(["-lc", &script])
|
||
.env("PATH", &path)
|
||
.status()
|
||
.unwrap();
|
||
assert_eq!(first.code(), Some(2));
|
||
let repaired = fs::read_to_string(&conf).unwrap();
|
||
assert!(!repaired.lines().any(|line| line == "=\"\""));
|
||
assert_eq!(repaired.matches("USE_KDUMP=").count(), 1);
|
||
assert!(repaired.contains("USE_KDUMP=\"1\""));
|
||
assert!(repaired.contains("KDUMP_COREDIR=\"/var/crash\""));
|
||
assert!(repaired.contains("CORE_COLLECTOR=\"makedumpfile -l --message-level 1 -d 31\""));
|
||
|
||
let second = Command::new("sh")
|
||
.args(["-lc", &script])
|
||
.env("PATH", path)
|
||
.status()
|
||
.unwrap();
|
||
assert!(second.success());
|
||
assert_eq!(fs::read_to_string(conf).unwrap(), repaired);
|
||
}
|
||
|
||
#[test]
|
||
fn crash_pruning_ignores_kdump_bookkeeping_files() {
|
||
use std::{fs, process::Command};
|
||
|
||
let root = tempfile::tempdir().unwrap();
|
||
let crash = root.path();
|
||
fs::write(crash.join("kdump_lock"), []).unwrap();
|
||
fs::write(crash.join("kexec_cmd"), "kexec -p").unwrap();
|
||
|
||
for (name, epoch) in [("old dump", "100"), ("middle", "200"), ("newest", "300")] {
|
||
let path = crash.join(name);
|
||
fs::create_dir(&path).unwrap();
|
||
fs::write(path.join("vmcore"), name).unwrap();
|
||
assert!(Command::new("touch")
|
||
.args(["-d", &format!("@{epoch}")])
|
||
.arg(&path)
|
||
.status()
|
||
.unwrap()
|
||
.success());
|
||
}
|
||
|
||
let status = Command::new("sh")
|
||
.args(["-lc", &crash_dump_prune_script()])
|
||
.env("ARCHIPELAGO_CRASH_DIR", crash)
|
||
.status()
|
||
.unwrap();
|
||
assert_eq!(status.code(), Some(2));
|
||
assert!(!crash.join("old dump").exists());
|
||
assert!(crash.join("middle").join("vmcore").exists());
|
||
assert!(crash.join("newest").join("vmcore").exists());
|
||
assert!(crash.join("kdump_lock").exists());
|
||
assert!(crash.join("kexec_cmd").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn crash_pruning_does_nothing_when_only_bookkeeping_files_exist() {
|
||
use std::{fs, process::Command};
|
||
|
||
let root = tempfile::tempdir().unwrap();
|
||
for name in ["kdump_lock", "kexec_cmd", "another-marker"] {
|
||
fs::write(root.path().join(name), []).unwrap();
|
||
}
|
||
let status = Command::new("sh")
|
||
.args(["-lc", &crash_dump_prune_script()])
|
||
.env("ARCHIPELAGO_CRASH_DIR", root.path())
|
||
.status()
|
||
.unwrap();
|
||
assert!(status.success());
|
||
assert_eq!(fs::read_dir(root.path()).unwrap().count(), 3);
|
||
}
|
||
}
|