diff options
| -rw-r--r-- | docs/post-install-checklist.org | 26 | ||||
| -rwxr-xr-x | scripts/post-rebuild-check | 407 | ||||
| -rw-r--r-- | tests/post-rebuild-check/test_post_rebuild_check.py | 684 |
3 files changed, 1117 insertions, 0 deletions
diff --git a/docs/post-install-checklist.org b/docs/post-install-checklist.org index 97fc0d5..f0545a7 100644 --- a/docs/post-install-checklist.org +++ b/docs/post-install-checklist.org @@ -18,6 +18,32 @@ bluetooth pairing landed below. * Checklist +** Run the post-rebuild check first + +Before working through the manual steps below, run: + +#+begin_src sh +~/code/archsetup/scripts/post-rebuild-check +#+end_src + +It runs the five checks a rebuilt machine actually needs — failed units, +user units that are present but never enabled, =*.example= configs whose +real sibling is missing, gitignore-mode projects missing the working state +their own =.gitignore= names, and the signal-cli registration. Each prints +a line whether or not it finds anything; exit 1 means something needs +attention. + +These are the gaps velox hit within two days of its 2026-08-13 reinstall, +and three of the five looked fine on casual inspection: a stowed unit file, +an enabled-looking timer, a present git clone. Run it again a day or two +after the install, once timers have had a chance to fail. + +It normally finishes in a second or two. On a machine whose user systemd is +wedged it takes a couple of minutes instead, because every =systemctl= call +is bounded at five seconds and check 2 makes one per unit. That is the slow +case working as intended: it reports what it could not read rather than +hanging. Set =PRC_SYSTEMCTL_TIMEOUT= lower to cut the wait. + ** Pair bluetooth peripherals Pairing is inherently interactive (scan, pick the device, confirm), so it diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check new file mode 100755 index 0000000..8807f85 --- /dev/null +++ b/scripts/post-rebuild-check @@ -0,0 +1,407 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-or-later +# post-rebuild-check - the five checks a rebuilt machine actually needs. +# +# A rebuilt machine looks finished and isn't. Five gaps surfaced on velox +# within two days of the 2026-08-13 reinstall, and three of them LOOKED +# fine: a stowed unit file, an enabled timer, a present git clone. Each +# check below is cheap and turns a silent no-op into a visible line: +# +# 1. failed systemd units, user and system scope (calendar-sync failed +# every 15 minutes for two days with nobody watching) +# 2. user unit files present but not enabled (roam-sync and +# signal-receive came back linked and inert -- a unit file being +# present is not the same as running) +# 3. tracked *.example files whose real sibling is missing (three +# *.local.el were gone on velox; the .example survives in git, +# the real file never does) +# 4. gitignore-mode projects missing tooling paths their own .gitignore +# names (a reinstall drops every such project's untracked working +# state -- 374 files in .emacs.d's case -- and nothing carries it) +# 5. signal-cli holds no registered account (velox lost its +# registration, and because agent-text relays into this machine, +# that silently broke paging for the WHOLE fleet) +# +# The .gitignore rule in check 4 is what scopes it: a tooling path is only +# expected where the project's own .gitignore names it, so a project that +# never had a todo.org never flags. The ignore file is the project's own +# record of what it is supposed to hold untracked. +# +# EVERY PROBE FAILS CLOSED. A check that cannot run reports a finding, never +# a pass. This matters more here than anywhere else in the script: the whole +# point is catching silent no-ops, so a silent no-op in the checker would be +# the worst possible defect. `systemctl --user` exits 1 with empty output +# when there is no user bus -- over ssh, from cron, under sudo, on a TTY +# before the graphical session starts -- and reading that as "no failed +# units" would report a machine as healthy exactly when nothing was checked. +# +# Exit 0 when every check is clean, 1 when any check found something, +# 2 on usage error. +# +# Test seams (env; for each, set-but-empty means "the probe ran and found +# nothing", unset means "run the real probe"): +# PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system) +# PRC_UNIT_STATES newline list of "unit-file state" replacing the +# user-unit-dir enumeration + is-enabled calls +# PRC_LOCAL_SCAN_ROOTS NEWLINE-separated roots for the *.example scan +# (default: ~/.emacs.d ~/.dotfiles) +# PRC_PROJECT_ROOTS NEWLINE-separated project dirs for check 4 +# (default: ~/code/* ~/projects/* ~/.emacs.d +# ~/.dotfiles) +# PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output; "" = no account, +# the special value MISSING = binary absent +# PRC_SYSTEMCTL path to the systemctl binary (a fake, under test) +# PRC_SYSTEMCTL_TIMEOUT seconds to allow each systemctl call (default 5) +# +# Roots are newline-separated, not space-separated, because a POSIX +# `for root in $var` splits on spaces and turns one real directory into +# several imaginary missing ones. + +usage() { + cat <<'EOF' +post-rebuild-check - verify a rebuilt machine is actually finished + +Runs the five checks that caught velox's 2026-08 reinstall gaps: failed +units, present-but-inert user units, orphaned *.example configs, missing +per-project tooling state, and the signal-cli registration. + +Usage: post-rebuild-check [--help] + +Exit 0 when every check is clean, 1 when any check found something. +Every probe fails closed: a check that cannot run is a finding, not a pass. +EOF +} + +case "${1:-}" in + --help|-h) usage; exit 0 ;; + "") ;; + *) echo "post-rebuild-check: unknown argument: $1" >&2; usage >&2; exit 2 ;; +esac + +# Own the internal flags rather than inheriting them, so a caller's unrelated +# variable of the same name cannot manufacture or mask a finding. +TOTAL_FINDINGS=0 +CHECK_FINDINGS=0 +FINDING_LINES="" +signal_missing="" + +# Every systemctl call is bounded. A wedged user manager spins and answers +# nothing -- seen live on velox 2026-08-17, where `is-enabled`, `cat`, and +# `list-unit-files` all hung while `list-units` still returned. Unbounded, this +# script would hang on the first unit and never reach the remaining checks, +# which is a worse failure than reporting nothing: a check that hangs is its +# own outage, and the machine most in need of checking is the one it hangs on. +# A timeout yields empty output and a non-zero status, and both are already +# handled as findings, so bounding the call is all that is needed to fail closed. +SCTL_TIMEOUT=${PRC_SYSTEMCTL_TIMEOUT:-5} +SYSTEMCTL=${PRC_SYSTEMCTL:-systemctl} + +sctl() { + if command -v timeout >/dev/null 2>&1; then + timeout "$SCTL_TIMEOUT" "$SYSTEMCTL" "$@" + else + # Say so rather than dropping the bound silently: without timeout a + # wedged manager hangs this run indefinitely, and the whole point of + # the bound is that a check which hangs reports nothing at all. + [ -n "${sctl_unbounded_warned:-}" ] || { + echo "post-rebuild-check: timeout(1) not found — systemctl calls are UNBOUNDED and may hang" >&2 + sctl_unbounded_warned=1 + } + "$SYSTEMCTL" "$@" + fi +} + +WORK=${TMPDIR:-/tmp}/.post-rebuild-check.$$ +if ! mkdir "$WORK" 2>/dev/null; then + # Every check stages its input through a file in here. Without it each + # loop would read nothing and every check would come back clean, which is + # the one failure this script must never produce. + echo "post-rebuild-check: cannot create a work directory under ${TMPDIR:-/tmp}" >&2 + echo " nothing was checked; this is not a pass" >&2 + exit 1 +fi +trap 'rm -rf "$WORK"' EXIT HUP INT TERM + +STAGE="$WORK/stage" + +finding() { + CHECK_FINDINGS=$((CHECK_FINDINGS + 1)) + TOTAL_FINDINGS=$((TOTAL_FINDINGS + 1)) + FINDING_LINES="${FINDING_LINES} DEVIATION: $1 +" +} + +# Print the check's one visible line, then its findings. The visible line +# is the point: a silent no-op is exactly what let the gaps sit unseen. +report() { + if [ "$CHECK_FINDINGS" -eq 0 ]; then + echo "$1 — ok" + else + echo "$1 — $CHECK_FINDINGS finding(s)" + printf '%s' "$FINDING_LINES" + fi + CHECK_FINDINGS=0 + FINDING_LINES="" +} + +# Stage a value into $STAGE for the read loops. A failed write is fatal for +# the same reason a missing work directory is. +stage() { + if ! printf '%s\n' "$1" > "$STAGE" 2>/dev/null; then + echo "post-rebuild-check: cannot write $STAGE" >&2 + echo " nothing was checked; this is not a pass" >&2 + exit 1 + fi +} + +# --- 1. failed units ------------------------------------------------------ + +if [ -n "${PRC_FAILED_UNITS+set}" ]; then + failed=$PRC_FAILED_UNITS +else + failed="" + if user_out=$(sctl --user list-units --state=failed --no-legend --plain 2>/dev/null); then + failed=$(printf '%s' "$user_out" | awk 'NF {print "user:"$1}') + else + finding "could not query user units (no user bus?) — nothing was checked in this scope" + fi + if sys_out=$(sctl list-units --state=failed --no-legend --plain 2>/dev/null); then + failed="$failed +$(printf '%s' "$sys_out" | awk 'NF {print "system:"$1}')" + else + finding "could not query system units — nothing was checked in this scope" + fi +fi +stage "$failed" +while IFS= read -r line; do + [ -n "$line" ] || continue + scope=${line%%:*} + unit=${line#*:} + finding "$scope unit failed: $unit" +done < "$STAGE" +report "check 1/5: failed units" + +# --- 2. user unit files present but not enabled --------------------------- + +if [ -n "${PRC_UNIT_STATES+set}" ]; then + states=$PRC_UNIT_STATES +else + states="" + unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + if [ ! -d "$unit_dir" ]; then + finding "no user unit directory at $unit_dir — nothing was checked" + else + for f in "$unit_dir"/*.timer "$unit_dir"/*.service; do + # -L as well as -e: a stow symlink whose target moved in the + # rebuild is exactly the "looked fine" case this check is for, + # and -e is false for a broken link. + [ -e "$f" ] || [ -L "$f" ] || continue + name=$(basename "$f") + # A link with nothing behind it is its own finding, decided on the + # filesystem rather than from systemd. `is-enabled` calls a + # dangling link "not-found" -- the same answer it gives for a unit + # that was never installed -- so routing this through the state + # table below would drop it silently. + if [ -L "$f" ] && [ ! -e "$f" ]; then + finding "stowed unit file points at a missing target: $name" + continue + fi + # is-enabled exits non-zero AND prints a state for disabled and + # linked, so the exit code cannot distinguish "this unit is + # disabled" from "the query failed". The output can: a real answer + # is always a word. Empty means no answer, which is a finding + # rather than a silent skip -- with no user bus (ssh, cron, sudo, + # a TTY before the graphical session) every unit answers empty, + # and treating that as unknown-so-ignore would pass the machine + # while reading nothing at all. + # + # No separate bus probe: `is-system-running` and + # `show-environment` both block here, and a check that can hang is + # its own outage. + state=$(sctl --user is-enabled "$name" 2>/dev/null) + if [ -z "$state" ]; then + finding "could not read the enablement state of $name — it was not checked" + continue + fi + states="${states}${name} ${state} +" + done + fi +fi +stage "$states" +# A second copy for the sibling-timer lookup below, so the awk that reads it +# is never the same open file as the loop reading it. +cp "$STAGE" "$WORK/states" 2>/dev/null || { + echo "post-rebuild-check: cannot write $WORK/states" >&2 + echo " nothing was checked; this is not a pass" >&2; exit 1; } +while read -r name state; do + [ -n "$name" ] || continue + case "$state" in + disabled|linked) ;; + *) continue ;; + esac + # A timer-activated service is SUPPOSED to sit linked-and-not-enabled: + # the timer owns activation, and enabling the service as well would run + # it at boot on top of its schedule. So a service is suppressed only when + # its sibling timer can actually start it (enabled), or when the timer is + # itself inert and therefore the finding already -- reporting both would + # name one gap twice. A masked, static, or not-found timer starts + # nothing, so the service beneath it is as dead as one with no timer. + case "$name" in + *.service) + timer="${name%.service}.timer" + tstate=$(awk -v t="$timer" '$1 == t {print $2; exit}' "$WORK/states") + # enabled-runtime (enabled until reboot) and generated (something + # produced and installed it) are live activation paths, so the + # service under one is being started and is not a finding. + # disabled and linked suppress for a different reason: the timer + # is then the finding itself, reported in its own right. + # + # "indirect" deliberately does NOT suppress. It means the unit + # file itself is not enabled, only that some Also= relative might + # be, so nothing here is known to start the service. The + # fail-closed rule says the uncertain case flags. + case "$tstate" in + enabled|enabled-runtime|generated) continue ;; + disabled|linked) continue ;; + esac + ;; + esac + finding "unit file present but not enabled: $name ($state)" +done < "$STAGE" +report "check 2/5: unit files" + +# --- 3. *.example files whose real sibling is missing --------------------- + +if [ -n "${PRC_LOCAL_SCAN_ROOTS+set}" ]; then + scan_roots=$PRC_LOCAL_SCAN_ROOTS +else + scan_roots="$HOME/.emacs.d +$HOME/.dotfiles" +fi +printf '%s\n' "$scan_roots" > "$WORK/roots" 2>/dev/null || { + echo "post-rebuild-check: cannot write $WORK/roots" >&2; exit 1; } +while IFS= read -r root; do + [ -n "$root" ] || continue + if [ ! -d "$root" ]; then + finding "scan root missing: $root" + continue + fi + # Vendored package trees ship their own .example docs; those belong to + # the package, not to this machine, so they are noise in front of the + # real findings this check exists for. + # + # -prune, not -not -path: the latter filters find's OUTPUT while still + # descending, so an unreadable directory inside a tree we deliberately + # ignore would set find's exit status and be reported as an unscanned + # part of the root. Pruning means those trees are never entered, so the + # exit status only reflects places this check actually wanted to read. + # + # That status matters: find exits non-zero when it cannot descend + # somewhere, having printed only what it could reach. Discarding it would + # hide every orphan under an unreadable directory behind a clean "ok", + # which is the defect this script exists to catch. + if ! find "$root" \ + \( -name .git -o -name elpa -o -name straight \ + -o -name node_modules -o -name .venv \) -prune \ + -o -name '*.example' -print > "$WORK/examples" 2>/dev/null; then + finding "could not fully scan $root — part of it was not checked" + fi + while IFS= read -r ex; do + [ -n "$ex" ] || continue + # -e, so a sibling that exists only as a dangling symlink counts as + # missing. It is not a config the machine can read. + [ -e "${ex%.example}" ] || finding "example without its real file: $ex" + done < "$WORK/examples" +done < "$WORK/roots" +report "check 3/5: local files" + +# --- 4. gitignore-mode projects missing their tooling --------------------- + +if [ -n "${PRC_PROJECT_ROOTS+set}" ]; then + projects=$PRC_PROJECT_ROOTS +else + projects=$(ls -d "$HOME"/code/*/ "$HOME"/projects/*/ 2>/dev/null; \ + printf '%s\n%s\n' "$HOME/.emacs.d" "$HOME/.dotfiles") +fi +printf '%s\n' "$projects" > "$WORK/projects" 2>/dev/null || { + echo "post-rebuild-check: cannot write $WORK/projects" >&2; exit 1; } +# CLAUDE.md is deliberately absent from this set. It is seed-only -- +# install-lang writes it once and the project owns it afterward -- so most +# projects legitimately never have one, and ratio shows the identical +# absences in the identical projects. That match is what proves it is the +# steady state rather than reinstall drift, and flagging it would put nine +# standing findings in front of every real one. +# +# The list is fed to the inner loop straight from a heredoc rather than +# staged through a file. It is a constant, so a file bought nothing and cost +# a fifth unguarded write: had it failed (a full tmpfs, say) the inner loop +# would read nothing and every project would pass silently, which is the one +# outcome this script must never produce. The heredoc is the inner loop's own +# stdin and leaves the outer loop's redirect alone. +while IFS= read -r proj; do + [ -n "$proj" ] || continue + proj=${proj%/} + # -e not -d: in a worktree or submodule .git is a file naming the real + # gitdir, and a -d test would skip those projects silently. + [ -e "$proj/.git" ] || continue + [ -f "$proj/.gitignore" ] || continue + while read -r disk pattern; do + # Both the anchored (/.ai/) and unanchored (.ai/) ignore styles exist + # across the fleet; the sweep-gitignore audit hit exactly that split. + # + # grep exits 1 for no-match and 2 for an error, so the two are told + # apart rather than both read as "the ignore file does not name this". + # An unreadable .gitignore would otherwise pass the whole project. + grep -Eq "^/?${pattern}/?\$" "$proj/.gitignore" 2>/dev/null + case $? in + 0) [ -e "$proj/$disk" ] \ + || finding "$proj: .gitignore names $disk but it is missing on disk" ;; + 1) ;; + *) finding "$proj: could not read .gitignore — the project was not checked" + break ;; + esac + done <<'EOF' +.ai \.ai +.claude \.claude +todo.org todo\.org +inbox inbox +EOF +done < "$WORK/projects" +report "check 4/5: project tooling" + +# --- 5. signal-cli registration ------------------------------------------- + +if [ -n "${PRC_SIGNAL_ACCOUNTS+set}" ]; then + accounts=$PRC_SIGNAL_ACCOUNTS + if [ "$accounts" = "MISSING" ]; then + accounts="" + signal_missing=1 + fi +else + if command -v signal-cli >/dev/null 2>&1; then + if ! accounts=$(signal-cli listAccounts 2>/dev/null); then + accounts="" + finding "signal-cli listAccounts failed — the registration was not checked" + signal_missing=skip + fi + else + accounts="" + signal_missing=1 + fi +fi +if [ "$signal_missing" = 1 ]; then + finding "signal-cli is not installed — paging relies on it fleet-wide" +elif [ -z "$signal_missing" ] && [ -z "$accounts" ]; then + finding "no signal account registered — agent-text relays into this machine, so paging breaks for the whole fleet" +fi +report "check 5/5: signal registration" + +# --- summary -------------------------------------------------------------- + +if [ "$TOTAL_FINDINGS" -eq 0 ]; then + echo "all checks clean" + exit 0 +fi +echo "$TOTAL_FINDINGS finding(s) across 5 checks" +exit 1 diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py new file mode 100644 index 0000000..4894451 --- /dev/null +++ b/tests/post-rebuild-check/test_post_rebuild_check.py @@ -0,0 +1,684 @@ +"""Tests for the post-rebuild-check script. + +A rebuilt machine looks finished and isn't: on velox 2026-08-13 five gaps +surfaced within two days, three of which LOOKED fine (a stowed unit file, an +enabled timer, a present git clone). The script runs the five checks from the +post-rebuild task and turns each silent no-op into a visible line: + + 1. failed systemd units (user and system scope) + 2. user unit files present but not enabled (linked-and-inert timers) + 3. tracked *.example files whose real sibling is missing + 4. gitignore-mode projects missing tooling paths their own .gitignore names + 5. signal-cli holds no registered account + +Exit 0 with every check clean, 1 when any check found something. + +Test seams (env vars the production script honors; for each, SET-BUT-EMPTY +means "the real probe ran and found nothing", UNSET means "run the real +probe"): + PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system) + PRC_UNIT_STATES newline list of "unit-file state" for the user unit dir + PRC_LOCAL_SCAN_ROOTS newline-separated roots to scan for *.example orphans + PRC_PROJECT_ROOTS newline-separated project dirs for the tooling check + PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output ("" = no accounts); + the special value MISSING means the binary is absent + +Run from repo root: + python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check +""" + +import os +import subprocess +import tempfile +import time +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check") + + +def run_check(failed_units="", unit_states="", local_roots="", + project_roots="", signal_accounts="+15045551234"): + """Run the script with every probe stubbed; defaults are all-clean. + + Roots are newline-separated. Empty means "the seam is set and names no + roots" -- the script tests with ${VAR+set}, so an empty value is still + set and never falls through to the real probe. + """ + env = dict(os.environ) + env["PRC_FAILED_UNITS"] = failed_units + env["PRC_UNIT_STATES"] = unit_states + env["PRC_LOCAL_SCAN_ROOTS"] = local_roots + env["PRC_PROJECT_ROOTS"] = project_roots + env["PRC_SIGNAL_ACCOUNTS"] = signal_accounts + return subprocess.run( + ["sh", CHECK], capture_output=True, text=True, timeout=30, env=env, + ) + + +class AllClean(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_all_clean_exits_zero(self): + r = run_check() + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + + def test_all_clean_prints_one_line_per_check(self): + # The visible line per check is the point of the script: a silent + # no-op is exactly what let the velox gaps sit unseen for two days. + r = run_check() + for label in ("failed units", "unit files", "local files", + "project tooling", "signal"): + self.assertIn(label, r.stdout.lower()) + + def test_all_clean_summary_says_clean(self): + r = run_check() + self.assertIn("all checks clean", r.stdout.lower()) + + def test_all_clean_no_deviation_lines(self): + r = run_check() + self.assertNotIn("DEVIATION", r.stdout) + + +class FailedUnits(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_failed_user_unit_flags(self): + r = run_check(failed_units="user:calendar-sync.service") + self.assertEqual(r.returncode, 1) + self.assertIn("calendar-sync.service", r.stdout) + self.assertIn("DEVIATION", r.stdout) + + def test_failed_system_unit_flags(self): + r = run_check(failed_units="system:tlp.service") + self.assertEqual(r.returncode, 1) + self.assertIn("tlp.service", r.stdout) + + def test_multiple_failed_units_each_reported(self): + r = run_check( + failed_units="user:calendar-sync.service\nsystem:tlp.service") + self.assertIn("calendar-sync.service", r.stdout) + self.assertIn("tlp.service", r.stdout) + + # --- Boundary cases ------------------------------------------------- + + def test_blank_lines_in_seam_ignored(self): + r = run_check(failed_units="\n\nuser:a.service\n\n") + self.assertEqual(r.returncode, 1) + self.assertIn("a.service", r.stdout) + + +class UnitFilesNotEnabled(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_disabled_timer_flags(self): + r = run_check(unit_states="roam-sync.timer disabled") + self.assertEqual(r.returncode, 1) + self.assertIn("roam-sync.timer", r.stdout) + + def test_linked_timer_flags(self): + # The exact velox case: a unit symlinked into the user dir by hand, + # never enabled — present, inert, and it LOOKS installed. + r = run_check(unit_states="signal-receive.timer linked") + self.assertEqual(r.returncode, 1) + self.assertIn("signal-receive.timer", r.stdout) + + def test_enabled_timer_passes(self): + r = run_check(unit_states="roam-sync.timer enabled") + self.assertEqual(r.returncode, 0, r.stdout) + + def test_static_service_passes(self): + # A service with no [Install] section is pulled in by its timer; + # "static" is its healthy state, not a gap. + r = run_check(unit_states="roam-sync.service static") + self.assertEqual(r.returncode, 0, r.stdout) + + def test_disabled_service_flags(self): + r = run_check(unit_states="obsbot-wb-guard.service disabled") + self.assertEqual(r.returncode, 1) + self.assertIn("obsbot-wb-guard.service", r.stdout) + + # --- Boundary cases ------------------------------------------------- + + def test_mixed_states_only_inert_reported(self): + r = run_check(unit_states="a.timer enabled\nb.timer disabled\n" + "c.service static\nd.service linked") + self.assertEqual(r.returncode, 1) + self.assertNotIn("a.timer", r.stdout) + self.assertIn("b.timer", r.stdout) + self.assertNotIn("c.service", r.stdout) + self.assertIn("d.service", r.stdout) + + def test_masked_unit_passes(self): + # Masking is a deliberate act (ppd on laptops), not rebuild rot. + r = run_check(unit_states="power-profiles-daemon.service masked") + self.assertEqual(r.returncode, 0, r.stdout) + + def test_service_whose_timer_is_enabled_passes(self): + # A timer-activated service is SUPPOSED to sit linked-not-enabled: + # the timer owns activation, and enabling the service too would run + # it at boot as well. Six of velox's units are this shape, and + # flagging them is the noise that gets a check ignored. + r = run_check(unit_states="roam-sync.service linked\n" + "roam-sync.timer enabled") + self.assertEqual(r.returncode, 0, r.stdout) + + def test_service_whose_timer_is_inert_flags_the_timer_only(self): + # When the timer itself never got enabled, the timer is the finding. + # Naming the service too would double-count one gap. + r = run_check(unit_states="obs-record-watchdog.service linked\n" + "obs-record-watchdog.timer linked") + self.assertEqual(r.returncode, 1) + self.assertIn("obs-record-watchdog.timer", r.stdout) + self.assertNotIn("obs-record-watchdog.service", r.stdout) + + def test_service_without_a_timer_still_flags(self): + # Nothing else can start it, so linked-not-enabled means dead. + r = run_check(unit_states="emacs.service linked") + self.assertEqual(r.returncode, 1) + self.assertIn("emacs.service", r.stdout) + + def test_a_runtime_enabled_timer_suppresses_its_service(self): + # enabled-runtime is a live activation path (enabled until reboot) + # and generated means something produced and installed it, so the + # service beneath either is being started and is not a finding. + for state in ("enabled-runtime", "generated"): + with self.subTest(timer=state): + r = run_check(unit_states=f"foo.service linked\n" + f"foo.timer {state}") + self.assertEqual(r.returncode, 0, + f"a {state} timer failed to suppress") + + def test_an_indirect_timer_does_not_suppress_its_service(self): + # "indirect" means the unit file itself is NOT enabled -- only that + # some Also= relative might be. Under this script's own fail-closed + # rule the uncertain case flags, so suppressing here would be the + # masked blind spot again in a narrower form. + r = run_check(unit_states="foo.service linked\nfoo.timer indirect") + self.assertEqual(r.returncode, 1, + "an indirect timer suppressed a service nothing starts") + self.assertIn("foo.service", r.stdout) + + def test_a_service_whose_timer_cannot_start_it_still_flags(self): + # Suppression is earned by a timer that can actually run the service. + # A masked, static, or absent timer starts nothing, so the service is + # as dead as one with no timer at all -- and suppressing on the mere + # presence of a timer line hides exactly that. + for state in ("masked", "static", "not-found"): + with self.subTest(timer=state): + r = run_check(unit_states=f"foo.service linked\n" + f"foo.timer {state}") + self.assertEqual(r.returncode, 1, + f"a {state} timer suppressed a dead service") + self.assertIn("foo.service", r.stdout) + + +class LocalExampleOrphans(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_example_without_sibling_flags(self): + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, "auth.local.el.example"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 1) + self.assertIn("auth.local.el.example", r.stdout) + + def test_a_real_file_that_is_a_dangling_symlink_still_flags(self): + # A sibling that exists only as a broken link is not a config the + # machine can read, so it is the same gap as an absent one. + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, "auth.local.el.example"), "w").close() + os.symlink("/nonexistent/stow/target", + os.path.join(root, "auth.local.el")) + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 1, + "a dangling sibling counted as present") + self.assertIn("auth.local.el.example", r.stdout) + + def test_example_with_sibling_passes(self): + with tempfile.TemporaryDirectory() as root: + open(os.path.join(root, "auth.local.el.example"), "w").close() + open(os.path.join(root, "auth.local.el"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_nested_example_found(self): + with tempfile.TemporaryDirectory() as root: + sub = os.path.join(root, "modules") + os.makedirs(sub) + open(os.path.join(sub, "mail.local.el.example"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 1) + self.assertIn("mail.local.el.example", r.stdout) + + # --- Boundary cases ------------------------------------------------- + + def test_two_roots_both_scanned(self): + with tempfile.TemporaryDirectory() as a, \ + tempfile.TemporaryDirectory() as b: + open(os.path.join(a, "one.example"), "w").close() + open(os.path.join(b, "two.example"), "w").close() + r = run_check(local_roots=a + "\n" + b) + self.assertIn("one.example", r.stdout) + self.assertIn("two.example", r.stdout) + + def test_vendored_package_dirs_not_scanned(self): + # elpa/ and friends hold third-party packages that ship their own + # .example docs. Those are the package's business, not this machine's, + # and one of them (dirvish's) was the only finding check 3 produced on + # velox — a standing false positive in front of any real one. + with tempfile.TemporaryDirectory() as root: + for vendor in ("elpa", "node_modules", ".venv", "straight"): + d = os.path.join(root, vendor, "pkg-1.0", "docs") + os.makedirs(d) + open(os.path.join(d, "config.example"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_an_unreadable_vendored_dir_is_not_a_finding(self): + # The vendored trees are excluded by design, so failing to descend + # into one is not a gap in what this check covers. Filtering find's + # output without pruning its descent turns a package directory + # nobody wanted read into a standing "could not fully scan". + with tempfile.TemporaryDirectory() as root: + locked = os.path.join(root, "elpa", "pkg-1.0") + os.makedirs(locked) + os.chmod(locked, 0o000) + try: + r = run_check(local_roots=root) + finally: + os.chmod(locked, 0o755) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_git_dir_not_scanned(self): + # .git holds hooks' sample files; those are git's, not the tree's. + with tempfile.TemporaryDirectory() as root: + g = os.path.join(root, ".git", "hooks") + os.makedirs(g) + open(os.path.join(g, "pre-commit.example"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + # --- Error cases ---------------------------------------------------- + + def test_an_unreadable_subdirectory_is_a_finding_not_a_pass(self): + # find exits non-zero when it cannot descend somewhere, and prints + # what it did reach. Discarding that status hides every orphan under + # the unreadable directory behind a clean "ok" -- the same defect + # class as a probe that cannot run reading as a pass. + with tempfile.TemporaryDirectory() as root: + locked = os.path.join(root, "locked") + os.makedirs(locked) + open(os.path.join(locked, "auth.local.el.example"), "w").close() + os.chmod(locked, 0o000) + try: + r = run_check(local_roots=root) + finally: + os.chmod(locked, 0o755) + self.assertEqual(r.returncode, 1, + "an unreadable directory read as nothing to check") + self.assertIn("could not", r.stdout.lower()) + + def test_missing_root_is_its_own_finding(self): + # A scan root that's gone is a rebuild gap too, not a pass. + r = run_check(local_roots="/nonexistent/scan-root") + self.assertEqual(r.returncode, 1) + self.assertIn("/nonexistent/scan-root", r.stdout) + + def test_a_path_with_spaces_is_one_root_not_three(self): + # Roots arrive newline-separated for this reason: splitting on spaces + # turns one real directory into several imaginary missing ones. + with tempfile.TemporaryDirectory() as base: + root = os.path.join(base, "a dir with spaces") + os.makedirs(root) + open(os.path.join(root, "orphan.example"), "w").close() + r = run_check(local_roots=root) + self.assertEqual(r.stdout.count("DEVIATION"), 1, r.stdout) + self.assertIn("orphan.example", r.stdout) + + +class ProjectTooling(unittest.TestCase): + def project(self, root, gitignore_lines, present=()): + os.makedirs(os.path.join(root, ".git")) + with open(os.path.join(root, ".gitignore"), "w") as f: + f.write("\n".join(gitignore_lines) + "\n") + for p in present: + path = os.path.join(root, p) + if p.endswith("/"): + os.makedirs(path, exist_ok=True) + else: + open(path, "w").close() + + # --- Normal cases --------------------------------------------------- + + def test_ignored_but_absent_tooling_flags(self): + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/", ".claude/", "todo.org"]) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 1) + for missing in (".ai", ".claude", "todo.org"): + self.assertIn(missing, r.stdout) + + def test_ignored_and_present_tooling_passes(self): + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/", "CLAUDE.md"], + present=(".ai/", "CLAUDE.md")) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_claude_md_absence_never_flags(self): + # CLAUDE.md is seed-only: install-lang writes it once and the project + # owns it afterward, so most projects legitimately never have one. + # Ratio shows the identical absences in the identical projects, which + # is what proves it is the steady state and not reinstall drift. + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/", "CLAUDE.md"], present=(".ai/",)) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_missing_ai_dir_still_flags(self): + # The one that carries real working state — 374 files in .emacs.d's + # case — and that nothing restores: not git, not stow, not bootstrap. + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/", "CLAUDE.md"]) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 1) + self.assertIn(".ai", r.stdout) + self.assertNotIn("CLAUDE.md", r.stdout) + + def test_unignored_tooling_never_expected(self): + # A project that never gitignored todo.org never had one to lose; + # the project's own .gitignore is the record of what it should hold. + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/"], present=(".ai/",)) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + # --- Boundary cases ------------------------------------------------- + + def test_anchored_ignore_style_recognized(self): + # Both /.ai/ (anchored) and .ai/ (unanchored) styles exist across + # the fleet; the sweep-gitignore audit hit exactly this split. + with tempfile.TemporaryDirectory() as root: + self.project(root, ["/.ai/", "/CLAUDE.md"]) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 1) + self.assertIn(".ai", r.stdout) + + def test_a_worktree_or_submodule_is_still_a_project(self): + # In a worktree or submodule, .git is a file pointing at the real + # gitdir rather than a directory, so a -d test skips the project + # silently. + with tempfile.TemporaryDirectory() as root: + with open(os.path.join(root, ".git"), "w") as f: + f.write("gitdir: /somewhere/else\n") + with open(os.path.join(root, ".gitignore"), "w") as f: + f.write(".ai/\n") + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 1) + self.assertIn(".ai", r.stdout) + + def test_an_unreadable_gitignore_is_a_finding_not_a_pass(self): + # grep exits 2 on error and 1 on no-match, so treating both as + # "nothing named" lets an unreadable ignore file pass the project + # silently. + with tempfile.TemporaryDirectory() as root: + self.project(root, [".ai/"]) + os.chmod(os.path.join(root, ".gitignore"), 0o000) + try: + r = run_check(project_roots=root) + finally: + os.chmod(os.path.join(root, ".gitignore"), 0o644) + self.assertEqual(r.returncode, 1, + "an unreadable .gitignore read as nothing to check") + self.assertIn("could not", r.stdout.lower()) + + def test_non_git_dir_skipped(self): + with tempfile.TemporaryDirectory() as root: + with open(os.path.join(root, ".gitignore"), "w") as f: + f.write(".ai/\n") + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_project_without_gitignore_skipped(self): + with tempfile.TemporaryDirectory() as root: + os.makedirs(os.path.join(root, ".git")) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + def test_unrelated_ignore_lines_no_flags(self): + with tempfile.TemporaryDirectory() as root: + self.project(root, ["*.pyc", "node_modules/", "dist/"]) + r = run_check(project_roots=root) + self.assertEqual(r.returncode, 0, r.stdout) + + +class SignalAccount(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_registered_account_passes(self): + r = run_check(signal_accounts="Number: +15045173983 ...") + self.assertEqual(r.returncode, 0, r.stdout) + + def test_no_account_flags(self): + # The velox case: a wiped registration silently breaks paging for + # the whole fleet, because agent-text relays into this machine. + r = run_check(signal_accounts="") + self.assertEqual(r.returncode, 1) + self.assertIn("signal", r.stdout.lower()) + + # --- Error cases ---------------------------------------------------- + + def test_missing_binary_flags(self): + r = run_check(signal_accounts="MISSING") + self.assertEqual(r.returncode, 1) + self.assertIn("signal-cli", r.stdout) + + def test_a_stray_signal_missing_in_the_environment_is_ignored(self): + # The script's own internal flag must not be settable from outside, + # or a caller's unrelated variable turns a registered account into a + # "signal-cli is not installed" finding. + env = dict(os.environ) + env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "", + "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234", + "signal_missing": "1"}) + r = subprocess.run(["sh", CHECK], capture_output=True, text=True, + timeout=30, env=env) + self.assertEqual(r.returncode, 0, r.stdout) + + +class ProbeFailure(unittest.TestCase): + """A probe that could not run must never read as a clean check. + + This is the defect the whole script exists to catch, so it would be the + worst possible place to have it. `systemctl --user` exits 1 with empty + output when there is no user bus -- over ssh, from cron, under sudo, or on + a TTY before the graphical session starts. Reading that as "no failed + units" reports a machine as healthy precisely when nothing can be checked. + """ + + def unset(self, *names): + """Run with the named seams unset, so the real probes execute.""" + env = dict(os.environ) + env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "", + "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234"}) + for n in names: + env.pop(n, None) + env["XDG_RUNTIME_DIR"] = "/nonexistent-runtime-dir" + return subprocess.run(["sh", CHECK], capture_output=True, text=True, + timeout=30, env=env) + + # --- Error cases ---------------------------------------------------- + + def test_unreachable_user_bus_is_a_finding_not_a_pass(self): + r = self.unset("PRC_FAILED_UNITS") + self.assertEqual(r.returncode, 1, + "a failed probe reported the machine as clean") + self.assertIn("could not", r.stdout.lower()) + + def test_unreachable_user_bus_fails_the_unit_state_check_too(self): + r = self.unset("PRC_UNIT_STATES") + self.assertEqual(r.returncode, 1, + "a failed probe reported the machine as clean") + + def test_an_unusable_tmpdir_is_a_finding_not_a_pass(self): + # Every check stages its input through a temp file. If that write + # fails, each loop reads nothing and every check comes back clean -- + # with real findings passed in. + env = dict(os.environ) + env.update({"PRC_FAILED_UNITS": "user:calendar-sync.service", + "PRC_UNIT_STATES": "", "PRC_LOCAL_SCAN_ROOTS": "", + "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234", + "TMPDIR": "/nonexistent-tmp-dir"}) + r = subprocess.run(["sh", CHECK], capture_output=True, text=True, + timeout=30, env=env) + self.assertEqual(r.returncode, 1, + "an unwritable TMPDIR swallowed a real finding") + + +class RealUnitDirEnumeration(unittest.TestCase): + """Check 2's unseamed path, where the unit files are read off disk. + + The PRC_UNIT_STATES seam skips this enumeration entirely, so a defect in + it survives every seamed test. That is where the dangling-stow-link case + lives, and a dangling stow link is precisely the requirement's headline + example of a unit file that LOOKED fine. + """ + + def run_real(self, config_home): + env = dict(os.environ) + env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "", + "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234", + "XDG_CONFIG_HOME": config_home}) + env.pop("PRC_UNIT_STATES", None) + return subprocess.run(["sh", CHECK], capture_output=True, text=True, + timeout=30, env=env) + + # --- Boundary cases ------------------------------------------------- + + def test_a_dangling_stow_link_is_enumerated_not_skipped(self): + with tempfile.TemporaryDirectory() as home: + unit_dir = os.path.join(home, "systemd", "user") + os.makedirs(unit_dir) + os.symlink("/nonexistent/stow/roam-sync.timer", + os.path.join(unit_dir, "roam-sync.timer")) + r = self.run_real(home) + self.assertEqual(r.returncode, 1, + "a dangling stow link read as nothing to check") + self.assertIn("roam-sync.timer", r.stdout) + self.assertIn("missing target", r.stdout) + + # --- Error cases ---------------------------------------------------- + + def test_a_missing_unit_directory_is_a_finding(self): + with tempfile.TemporaryDirectory() as home: + r = self.run_real(home) + self.assertEqual(r.returncode, 1) + self.assertIn("no user unit directory", r.stdout) + + +class WedgedSystemctl(unittest.TestCase): + """A systemd manager that never answers must not hang the check. + + Seen live on velox 2026-08-17: the user manager spun at 96% CPU with + `is-enabled`, `cat`, and `list-unit-files` all hanging while `list-units` + still returned. Unbounded, the check stops at the first unit and never + runs checks 3 through 5, so the machine most in need of checking is the + one it reports nothing about. + """ + + def run_with_fake(self, script_body, timeout_s="1"): + """Run against a fake systemctl, with check 2's unit dir empty. + + Pointing XDG_CONFIG_HOME at an empty directory keeps check 2 from + making one call per real unit, so the test measures the bound rather + than the size of this machine's unit directory. + """ + with tempfile.TemporaryDirectory() as d: + fake = os.path.join(d, "systemctl") + with open(fake, "w") as f: + f.write(script_body) + os.chmod(fake, 0o755) + env = dict(os.environ) + env.update({"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "", + "PRC_SIGNAL_ACCOUNTS": "+15045551234", + "PRC_SYSTEMCTL": fake, + "PRC_SYSTEMCTL_TIMEOUT": timeout_s, + "XDG_CONFIG_HOME": d}) + env.pop("PRC_FAILED_UNITS", None) + env.pop("PRC_UNIT_STATES", None) + start = time.monotonic() + r = subprocess.run(["sh", CHECK], capture_output=True, + text=True, timeout=60, env=env) + return r, time.monotonic() - start + + # --- Error cases ---------------------------------------------------- + + def test_a_hanging_systemctl_is_bounded_and_reported(self): + r, _ = self.run_with_fake("#!/bin/sh\nsleep 300\n") + self.assertEqual(r.returncode, 1) + self.assertIn("could not query user units", r.stdout) + # The run must reach the end rather than stopping at the first call. + self.assertIn("check 5/5", r.stdout) + + def test_a_hanging_systemctl_does_not_stall_the_whole_run(self): + # The fake sleeps 8s against a 1s bound, so a bounded run lands near + # 2s (two calls) and an unbounded one near 16s. Deliberately short + # enough that losing the bound fails this assertion in seconds rather + # than hitting the subprocess ceiling a minute later -- a regression + # nobody waits out is a regression nobody catches. + _, elapsed = self.run_with_fake("#!/bin/sh\nsleep 8\n") + self.assertLess(elapsed, 6, + "the run was not bounded by PRC_SYSTEMCTL_TIMEOUT") + + +class Reporting(unittest.TestCase): + # --- Normal cases --------------------------------------------------- + + def test_findings_counted_in_summary(self): + # Assert the count in the summary line specifically. A bare + # assertIn("2") passes on the always-present "check 2/5" text, so it + # stays green even when the counter is arithmetically wrong. + r = run_check(failed_units="user:a.service\nuser:b.service", + unit_states="c.timer disabled") + self.assertEqual(r.returncode, 1) + summary = r.stdout.strip().splitlines()[-1] + self.assertEqual(summary, "3 finding(s) across 5 checks") + + def test_the_summary_count_tracks_every_check(self): + # One finding from each of the five, so a counter that drops or + # double-counts any single check shows up here. + with tempfile.TemporaryDirectory() as scan, \ + tempfile.TemporaryDirectory() as proj: + open(os.path.join(scan, "orphan.example"), "w").close() + os.makedirs(os.path.join(proj, ".git")) + with open(os.path.join(proj, ".gitignore"), "w") as f: + f.write(".ai/\n") + r = run_check(failed_units="user:a.service", + unit_states="b.timer disabled", + local_roots=scan, project_roots=proj, + signal_accounts="") + summary = r.stdout.strip().splitlines()[-1] + self.assertEqual(summary, "5 finding(s) across 5 checks") + + def test_help_exits_zero(self): + r = subprocess.run(["sh", CHECK, "--help"], + capture_output=True, text=True, timeout=10) + self.assertEqual(r.returncode, 0) + self.assertIn("post-rebuild-check", r.stdout) + + # --- Error cases ---------------------------------------------------- + + def test_unknown_flag_errors(self): + r = subprocess.run(["sh", CHECK, "--bogus"], + capture_output=True, text=True, timeout=10) + self.assertNotEqual(r.returncode, 0) + + +if __name__ == "__main__": + unittest.main() |
