From 9b0e05bfd87eaebe2088e1c5a869b7432461acce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 17:54:48 +0000 Subject: [PATCH] add three resource-pressure sections, each asked rather than assumed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap covers memory pressure. These are its neighbours: 8. Emergency disk ballast the same valve, for disk 9. earlyoom what happens when swap runs out too 10. inotify watch limit the silent one All three follow the rule this script now works to: the role sets which way the recommendation points, never whether the question is asked. A dev machine is still offered the ballast, with the recommendation pointing the other way; a server is still offered the inotify raise, because anything running `bun --watch` or serving a file browser is a watcher too. The ballast is section 22 of the original, moved up beside swap where it belongs and moved out of the user's home. The original wrote the checker into $USER_HOME/.local/bin and ran it from a root cron — a root cron executing a script in a directory its owner can write is a privilege escalation waiting to be noticed. Moot on a box where that user already has passwordless sudo, but wrong. Both the checker and the file are in root-owned system paths now. Two bugs found by running the generated checker rather than reading it: It df'd the ballast's own directory, which does not exist before the ballast is created — and with `set -euo pipefail` that meant cron mailing an error every ten minutes. It now walks up to a directory that exists, and the installer creates the directory itself rather than depending on the create step. The inotify text claimed a default of 8192. This host is at 29461: Ubuntu raised it, and stating a number the reader can see is wrong on their own screen undermines the rest of the explanation. It now describes the failure instead and prints the machine's actual value. earlyoom is a distro package and a systemd unit, so it is checked with `systemctl is-active` and reports honestly when it installs but fails to start. Verified: checker --status and its no-op path both exit 0 with no directory present, and the helpers report correctly against this host. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/system.sh | 162 +++++++++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 119 +++++++++++++- 2 files changed, 280 insertions(+), 1 deletion(-) diff --git a/scripts/setup/machine-setup/lib/system.sh b/scripts/setup/machine-setup/lib/system.sh index dd2654ba..ffc261d8 100644 --- a/scripts/setup/machine-setup/lib/system.sh +++ b/scripts/setup/machine-setup/lib/system.sh @@ -173,6 +173,168 @@ swappiness_set() { sysctl -q -w "vm.swappiness=$1" } +# ----------------------------------------------------------------------------- +# Emergency disk ballast +# ----------------------------------------------------------------------------- +# +# The same idea as swap, one layer down. Swap is the valve for memory pressure; +# this is the valve for disk pressure. +# +# A junk file holding no data, sized at 10% of free disk. Its only job is to be +# deleted when the filesystem is about to fill, buying enough headroom to log in +# and clean up properly instead of meeting a wedged box — Docker, journald and +# postgres all misbehave badly at 100% full, and some of them do not recover on +# their own. +# +# A one-shot valve: once spent, it has to be recreated. +# +# ── Moved out of the user's home ── +# +# The original put the checker in $USER_HOME/.local/bin and ran it from a root +# cron. A root cron executing a script inside a directory its owner can write is +# a privilege escalation waiting to be noticed — moot on a box where that user +# already has passwordless sudo, but wrong, and not something to carry forward. +# Both the script and the file now live in root-owned system paths. + +BALLAST_FILE=/var/lib/machine-setup/ballast.bin +BALLAST_CHECKER=/usr/local/sbin/emergency-disk-check +BALLAST_CRON=/etc/cron.d/emergency-disk-check +BALLAST_THRESHOLD=10 + +ballast_exists() { [[ -f "$BALLAST_FILE" ]]; } + +ballast_size_human() { du -h "$BALLAST_FILE" 2>/dev/null | cut -f1; } + +# 10% of what is free right now, in MiB. +ballast_size_mb() { + local free_kb + free_kb="$(df -Pk /var/lib | awk 'NR == 2 { print $4 }')" + echo $((free_kb / 1024 / 10)) +} + +ballast_create() { + local mb="$1" + mkdir -p "$(dirname "$BALLAST_FILE")" + + # fallocate reserves real blocks. A sparse file made with truncate would + # reserve nothing and free nothing when deleted, which is the entire point. + if ! fallocate -l "${mb}M" "$BALLAST_FILE" 2>/dev/null; then + info " fallocate is not usable here — writing with dd, which is slower" + dd if=/dev/zero of="$BALLAST_FILE" bs=1M count="$mb" status=none + fi + chmod 600 "$BALLAST_FILE" +} + +ballast_install_checker() { + mkdir -p "$(dirname "$BALLAST_FILE")" + cat >"$BALLAST_CHECKER" <"$BALLAST_CRON" </dev/null; } + +earlyoom_install() { + pkg_is_installed earlyoom || pkg_install_now earlyoom + systemctl enable --now earlyoom >/dev/null 2>&1 +} + +# ----------------------------------------------------------------------------- +# Resource limits +# ----------------------------------------------------------------------------- +# +# inotify watches: how many files one user can have the kernel watching. The +# stock limit is small enough that one file watcher walking +# node_modules. Every watcher on the machine draws from the same pool. +# +# The failure is silent, which is what makes it worth setting in advance: nothing +# errors, the watcher simply stops noticing changes. Hot reload goes quiet, a +# build stops rebuilding, and the reason is never on screen. +# +# Mostly a development concern, but not exclusively — anything running `bun +# --watch` or serving a file browser is a watcher too. + +INOTIFY_WATCHES=524288 +INOTIFY_INSTANCES=1024 + +inotify_current_watches() { sysctl -n fs.inotify.max_user_watches 2>/dev/null || echo 0; } + +inotify_raise() { + cat >/etc/sysctl.d/99-machine-setup-inotify.conf <= INOTIFY_WATCHES)); then + echo " already at or above that, nothing to do" + SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}") + elif confirm "Raise it?"; then + inotify_raise + ok "inotify watches raised to $(inotify_current_watches)" + SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}") + else + warn "skipped by request" + SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}") + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= @@ -324,7 +441,7 @@ fi # auto-suspend · boot-hang fix · user creation · # ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades · # git config · docker · zsh + prompt · tailscale · neovim · js runtimes · -# dev tools · ufw · zshrc · disk ballast +# dev tools · ufw · zshrc # # Each arrives as its own commit. Delete this block when the list is empty.