aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-08-20 00:17:09 -0700
committerCraig Jennings <c@cjennings.net>2026-08-20 00:17:09 -0700
commit87ff0b77cdc1d8a66a063cfe471dab78d1966dc4 (patch)
tree986d88e47d6fa3887acd5be4b6b595d3cddb9bb0 /tests
parenta028aa589056167160b39cf9e023c12dd30dec34 (diff)
downloadarchsetup-87ff0b77cdc1d8a66a063cfe471dab78d1966dc4.tar.gz
archsetup-87ff0b77cdc1d8a66a063cfe471dab78d1966dc4.zip
feat(post-rebuild-check): flag a stopped idle daemon and a read-only remote
Both are states where the machine looks finished and isn't, which is the whole point of this script. Check 7 asks whether hypridle is running. Nothing else notices when it isn't. Idle lock and suspend stop happening, and the laptop runs until its battery is gone. That's how velox reset its RTC on 2026-08-19, which is what dropped it into check 6's clock and DNS deadlock. The check asks whether the daemon is alive rather than why it might not be, so a crash and a stale caffeine surface alike. It's gated on hypridle being installed, since only Hyprland machines get it. Check 8 asks whether the working repos can push. The installer clones them from the read-only https endpoint. That's right for someone installing archsetup with no key on my server, and wrong for my own machines. Nothing about the tree shows it. velox's dotfiles remote sat that way for four days and announced itself as a 403. Only my own read-only endpoint is flagged. An https remote elsewhere may push fine through a credential helper, and guessing about hosts this machine doesn't own would stand noise in front of real findings.
Diffstat (limited to 'tests')
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py213
1 files changed, 202 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..9902299 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,10 @@ 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)
Run from repo root:
python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check
@@ -43,7 +50,8 @@ 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=""):
"""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 +65,8 @@ 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
return subprocess.run(
["sh", CHECK], capture_output=True, text=True, timeout=30, env=env,
)
@@ -76,7 +86,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 +98,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 +106,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 +163,8 @@ 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_CHRONY_CONF": chrony_conf})
env.pop("PRC_NTP_SOURCES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -177,7 +189,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 +211,161 @@ 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 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 +795,30 @@ 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": "",
"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 +865,8 @@ 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": "",
"TMPDIR": "/nonexistent-tmp-dir"})
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
@@ -701,6 +888,8 @@ 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": "",
"XDG_CONFIG_HOME": config_home})
env.pop("PRC_UNIT_STATES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -754,6 +943,8 @@ 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_SYSTEMCTL": fake,
"PRC_SYSTEMCTL_TIMEOUT": timeout_s,
"XDG_CONFIG_HOME": d})
@@ -771,7 +962,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 +986,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 +1002,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"],