aboutsummaryrefslogtreecommitdiff
path: root/tests/post-rebuild-check/test_post_rebuild_check.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/post-rebuild-check/test_post_rebuild_check.py')
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py317
1 files changed, 306 insertions, 11 deletions
diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py
index bad337e..a034f87 100644
--- a/tests/post-rebuild-check/test_post_rebuild_check.py
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -2,7 +2,7 @@
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
+enabled timer, a present git clone). The script runs the checks from the
post-rebuild task and turns each silent no-op into a visible line:
1. failed systemd units (user and system scope)
@@ -10,6 +10,9 @@ post-rebuild task and turns each silent no-op into a visible line:
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
+ 6. every NTP source named by hostname (a wrong clock takes DNS with it)
+ 7. hypridle installed but not running (nothing triggers idle suspend)
+ 8. a working repo cloned read-only (push returns 403)
Exit 0 with every check clean, 1 when any check found something.
@@ -24,6 +27,13 @@ probe"):
PRC_NTP_SOURCES newline list of configured NTP server addresses
("MISSING" = no NTP daemon active)
the special value MISSING means the binary is absent
+ PRC_IDLE_DAEMON pgrep output for hypridle ("" = installed but not
+ running; "MISSING" = not installed on this machine)
+ PRC_REPO_REMOTES newline list of "path<space>origin-url" for the
+ push-capability check ("" = no repos to check)
+ PRC_UNITS_EXPECTED_DISABLED
+ newline list of units whose not-enabled state is
+ deliberate on this machine ("" = no exemptions)
Run from repo root:
python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check
@@ -43,7 +53,9 @@ CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check")
def run_check(failed_units="", unit_states="", local_roots="",
project_roots="", signal_accounts="+15045551234",
- ntp_sources="162.159.200.1\npool.ntp.org"):
+ ntp_sources="162.159.200.1\npool.ntp.org",
+ idle_daemon="4242", repo_remotes="",
+ units_expected_disabled=""):
"""Run the script with every probe stubbed; defaults are all-clean.
Roots are newline-separated. Empty means "the seam is set and names no
@@ -57,6 +69,9 @@ def run_check(failed_units="", unit_states="", local_roots="",
env["PRC_PROJECT_ROOTS"] = project_roots
env["PRC_SIGNAL_ACCOUNTS"] = signal_accounts
env["PRC_NTP_SOURCES"] = ntp_sources
+ env["PRC_IDLE_DAEMON"] = idle_daemon
+ env["PRC_REPO_REMOTES"] = repo_remotes
+ env["PRC_UNITS_EXPECTED_DISABLED"] = units_expected_disabled
return subprocess.run(
["sh", CHECK], capture_output=True, text=True, timeout=30, env=env,
)
@@ -76,7 +91,7 @@ class NtpBootstrap(unittest.TestCase):
def test_an_ip_addressed_source_is_clean(self):
r = run_check(ntp_sources="162.159.200.1\npool.ntp.org")
- self.assertIn("check 6/6: NTP bootstrap — ok", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
self.assertEqual(r.returncode, 0, r.stdout)
def test_all_hostname_sources_is_a_finding(self):
@@ -88,7 +103,7 @@ class NtpBootstrap(unittest.TestCase):
def test_an_ipv6_addressed_source_counts(self):
r = run_check(ntp_sources="2606:4700:f1::1")
- self.assertIn("check 6/6: NTP bootstrap — ok", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
# --- Boundary cases -------------------------------------------------
@@ -96,11 +111,11 @@ class NtpBootstrap(unittest.TestCase):
# Order must not matter; the property is "at least one", and the
# drop-in that carries it is read after the main config.
r = run_check(ntp_sources="a.pool.ntp.org\nb.pool.ntp.org\n162.159.200.1")
- self.assertIn("check 6/6: NTP bootstrap — ok", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
def test_blank_lines_between_sources_are_ignored(self):
r = run_check(ntp_sources="\n\n162.159.200.1\n\n")
- self.assertIn("check 6/6: NTP bootstrap — ok", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
def test_a_hostname_containing_digits_and_dots_is_not_an_address(self):
# The trap in any naive "looks like an IP" test: these resolve through
@@ -153,6 +168,9 @@ class NtpBootstrap(unittest.TestCase):
env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"PRC_CHRONY_CONF": chrony_conf})
env.pop("PRC_NTP_SOURCES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -177,7 +195,7 @@ class NtpBootstrap(unittest.TestCase):
r = self._run_real_probe(conf)
if "no NTP implementation is active" in r.stdout:
self.skipTest("no chronyd on this host — the reader branch can't run")
- self.assertIn("check 6/6: NTP bootstrap — ok", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
def test_confdir_naming_an_empty_directory_is_not_a_pass(self):
# confdir present, nothing behind it: the sources are the hostname-only
@@ -199,7 +217,255 @@ class NtpBootstrap(unittest.TestCase):
env.pop("PRC_NTP_SOURCES", None)
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
- self.assertIn("check 6/6: NTP bootstrap", r.stdout)
+ self.assertIn("check 6/8: NTP bootstrap", r.stdout)
+
+
+class IdleDaemon(unittest.TestCase):
+ """Check 7 — whether anything still triggers idle lock and suspend.
+
+ A laptop that never sleeps has no symptom until the battery is gone, which
+ is why this needs a check rather than trusting the desktop to look right.
+ On velox 2026-08-19 hypridle started cleanly at 15:29:48 and `settings
+ restore` killed it six seconds later, replaying a caffeine stored in an
+ earlier boot. The machine then ran 11h40m fully awake on battery, died when
+ it flattened, and reset its RTC — which took DNS down with it, the very
+ deadlock check 6 exists for. Nothing looked wrong at any point.
+
+ The check is behavioural: it asks whether the daemon is alive, not why it
+ might not be, so a stale caffeine, a crash and a bad config all surface the
+ same way.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_running_daemon_is_clean(self):
+ r = run_check(idle_daemon="4242")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_installed_but_not_running_flags(self):
+ r = run_check(idle_daemon="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("hypridle", r.stdout)
+ self.assertIn("DEVIATION", r.stdout)
+
+ def test_the_finding_names_the_consequence_not_just_the_process(self):
+ # "hypridle is not running" reads as a detail. The reason it matters is
+ # that the machine stays awake until the battery is gone, and that is
+ # what has to be in the line someone skims at 1am.
+ r = run_check(idle_daemon="")
+ self.assertIn("awake", r.stdout.lower())
+
+ def test_the_finding_names_the_known_cause(self):
+ # Behavioural checks are cheap to write and expensive to act on. Naming
+ # the one cause already seen saves the reader the investigation this
+ # session had to do from scratch.
+ r = run_check(idle_daemon="")
+ self.assertIn("caffeine", r.stdout.lower())
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_not_installed_is_not_a_finding(self):
+ # A headless or dwm machine never installs hypridle — archsetup pulls
+ # it in only for Hyprland. Flagging its absence there would be noise on
+ # every run, and noise is how a real finding gets skimmed past.
+ r = run_check(idle_daemon="MISSING")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_several_pids_still_read_as_running(self):
+ # pgrep prints one pid per line. More than one is its own problem (five
+ # concurrent daemons wedged a velox session on 2026-07-22) but it is
+ # not *this* check's, and it must not read as "not running".
+ r = run_check(idle_daemon="4242\n4243")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+ def test_the_check_always_prints_its_line(self):
+ for pids in ("4242", "", "MISSING"):
+ with self.subTest(pids=pids):
+ self.assertIn("idle daemon",
+ run_check(idle_daemon=pids).stdout.lower())
+
+
+class UnitsExpectedDisabled(unittest.TestCase):
+ """Check 2 — units nothing intends to enable on this machine.
+
+ "Enabled" is the check's proxy for "will actually run", and the proxy is
+ wrong for a unit nobody means to enable here. velox carries four such
+ units, for four different reasons: geoclue-agent is redundant because
+ hyprland's exec-once starts the binary directly, emacs is started on demand
+ by emacsclient, obs-record-watchdog only matters while recording, and
+ obsbot-wb-guard needs an OBSBOT the machine doesn't have.
+
+ Left unexempted they report at every run, which is the standing-findings
+ problem check 4's own comment already argues against: four permanent lines
+ in front of every real one teach you to skim the output.
+
+ The exemption is machine-local rather than a marker in the shared unit
+ file, because obsbot-wb-guard is correctly ENABLED on ratio. Same unit,
+ different right answer per machine.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_exempt_unit_is_not_flagged(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn("emacs.service", r.stdout)
+
+ def test_a_non_exempt_unit_still_flags(self):
+ r = run_check(unit_states="roam-sync.timer linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("roam-sync.timer", r.stdout)
+
+ def test_several_exemptions_all_apply(self):
+ r = run_check(
+ unit_states=("emacs.service linked\n"
+ "geoclue-agent.service linked\n"
+ "obsbot-wb-guard.service linked"),
+ units_expected_disabled=("emacs.service\n"
+ "geoclue-agent.service\n"
+ "obsbot-wb-guard.service"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_an_exemption_that_is_actually_enabled_is_a_finding(self):
+ # A stale exemption must surface rather than sit there suppressing
+ # nothing. Otherwise the list rots into a place real findings go to
+ # die, which is worse than the noise it was added to remove.
+ r = run_check(unit_states="obsbot-wb-guard.service enabled",
+ units_expected_disabled="obsbot-wb-guard.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obsbot-wb-guard.service", r.stdout)
+
+ def test_comments_and_blank_lines_are_ignored(self):
+ # The reason a unit is exempt is the most useful thing about the
+ # entry, so the format has to hold a comment next to it.
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled=("# started on demand\n"
+ "\n"
+ "emacs.service # not by systemd\n"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_exemptions_flags_everything_as_before(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("emacs.service", r.stdout)
+
+ def test_an_exemption_does_not_suppress_a_dangling_link(self):
+ # A stowed unit pointing at a missing target is a different finding,
+ # decided on the filesystem. Exempting the name must not hide that.
+ d = tempfile.mkdtemp(prefix="prc-units-")
+ self.addCleanup(shutil.rmtree, d, True)
+ unit_dir = os.path.join(d, "systemd", "user")
+ os.makedirs(unit_dir)
+ link = os.path.join(unit_dir, "emacs.service")
+ os.symlink(os.path.join(d, "gone.service"), link)
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "emacs.service",
+ "XDG_CONFIG_HOME": d})
+ env.pop("PRC_UNIT_STATES", None)
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertIn("points at a missing target", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+
+class RepoPushCapability(unittest.TestCase):
+ """Check 8 — a working repo cloned from the read-only endpoint.
+
+ archsetup clones the user's own archsetup and dotfiles from
+ https://git.cjennings.net/..., the anonymous read-only endpoint. That is
+ the right default for a stranger installing archsetup, who has no key on
+ the server, and the wrong one for this machine, which has to push. The
+ override exists (ARCHSETUP_REPO / DOTFILES_REPO) but only applies where it
+ is configured — a curl|bash install, or a rebuild from a stock ISO, picks
+ the default straight back up.
+
+ Nothing about the tree shows it. The clone is complete and ordinary, and
+ the machine finds out at the first push, with a 403. That is how velox's
+ dotfiles remote was found on 2026-08-17, four days after its rebuild.
+
+ Only the read-only endpoint is flagged. An https remote to some other host
+ may well be pushable with a credential helper, and guessing about hosts
+ this machine does not own would put standing noise in front of the real
+ findings.
+ """
+
+ RO = "https://git.cjennings.net/dotfiles.git"
+ RW = "git@cjennings.net:dotfiles.git"
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_ssh_remote_is_clean(self):
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RW}")
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_the_read_only_endpoint_flags(self):
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RO}")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/.dotfiles", r.stdout)
+
+ def test_the_finding_names_the_consequence(self):
+ # "the remote is https" is a detail. That pushing fails is the point.
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RO}")
+ self.assertIn("push", r.stdout.lower())
+
+ def test_every_offending_repo_is_named(self):
+ r = run_check(repo_remotes=(f"/home/x/.dotfiles {self.RO}\n"
+ f"/home/x/code/archsetup {self.RO}"))
+ self.assertIn("/home/x/.dotfiles", r.stdout)
+ self.assertIn("/home/x/code/archsetup", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_a_mixed_set_flags_only_the_read_only_one(self):
+ r = run_check(repo_remotes=(f"/home/x/.dotfiles {self.RW}\n"
+ f"/home/x/code/archsetup {self.RO}"))
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/code/archsetup", r.stdout)
+ self.assertNotIn("/home/x/.dotfiles", r.stdout)
+
+ def test_an_https_remote_to_another_host_is_not_flagged(self):
+ # GitHub over https is pushable with a credential helper. Flagging it
+ # would be a guess about a host this machine does not own.
+ r = run_check(repo_remotes="/home/x/code/thing https://github.com/a/b.git")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_repos_is_not_a_finding(self):
+ r = run_check(repo_remotes="")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_blank_lines_are_ignored(self):
+ r = run_check(repo_remotes=f"\n\n/home/x/.dotfiles {self.RW}\n\n")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_the_check_always_prints_its_line(self):
+ for remotes in ("", f"/home/x/.dotfiles {self.RW}",
+ f"/home/x/.dotfiles {self.RO}"):
+ with self.subTest(remotes=remotes):
+ self.assertIn("repo remotes",
+ run_check(repo_remotes=remotes).stdout.lower())
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_repo_with_no_origin_is_a_finding(self):
+ # Fails closed. A repo whose origin could not be read was not checked,
+ # and reporting it clean is the false pass this script exists to avoid.
+ r = run_check(repo_remotes="/home/x/.dotfiles")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/.dotfiles", r.stdout)
class AllClean(unittest.TestCase):
@@ -629,11 +895,31 @@ class SignalAccount(unittest.TestCase):
"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
"PRC_NTP_SOURCES": "162.159.200.1",
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"signal_missing": "1"})
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
self.assertEqual(r.returncode, 0, r.stdout)
+ def test_a_stray_idle_absent_in_the_environment_is_ignored(self):
+ # Same class as the flag above, and the dangerous direction: an
+ # inherited idle_absent=1 would make check 7 skip silently and report
+ # ok on a machine that cannot sleep, which is the exact false pass the
+ # check exists to prevent.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "",
+ "idle_absent": "1"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 1, r.stdout)
+ self.assertIn("hypridle is installed but not running", r.stdout)
+
class ProbeFailure(unittest.TestCase):
"""A probe that could not run must never read as a clean check.
@@ -680,6 +966,9 @@ class ProbeFailure(unittest.TestCase):
"PRC_UNIT_STATES": "", "PRC_LOCAL_SCAN_ROOTS": "",
"PRC_PROJECT_ROOTS": "",
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"TMPDIR": "/nonexistent-tmp-dir"})
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
@@ -701,6 +990,9 @@ class RealUnitDirEnumeration(unittest.TestCase):
env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
"PRC_PROJECT_ROOTS": "",
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"XDG_CONFIG_HOME": config_home})
env.pop("PRC_UNIT_STATES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -754,6 +1046,9 @@ class WedgedSystemctl(unittest.TestCase):
env = dict(os.environ)
env.update({"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"PRC_SYSTEMCTL": fake,
"PRC_SYSTEMCTL_TIMEOUT": timeout_s,
"XDG_CONFIG_HOME": d})
@@ -771,7 +1066,7 @@ class WedgedSystemctl(unittest.TestCase):
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 6/6", r.stdout)
+ self.assertIn("check 8/8", 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
@@ -795,7 +1090,7 @@ class Reporting(unittest.TestCase):
unit_states="c.timer disabled")
self.assertEqual(r.returncode, 1)
summary = r.stdout.strip().splitlines()[-1]
- self.assertEqual(summary, "3 finding(s) across 6 checks")
+ self.assertEqual(summary, "3 finding(s) across 8 checks")
def test_the_summary_count_tracks_every_check(self):
# One finding from each of the five, so a counter that drops or
@@ -811,7 +1106,7 @@ class Reporting(unittest.TestCase):
local_roots=scan, project_roots=proj,
signal_accounts="")
summary = r.stdout.strip().splitlines()[-1]
- self.assertEqual(summary, "5 finding(s) across 6 checks")
+ self.assertEqual(summary, "5 finding(s) across 8 checks")
def test_help_exits_zero(self):
r = subprocess.run(["sh", CHECK, "--help"],