aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps')
-rw-r--r--tests/installer-steps/test_check_disk_space.py94
-rw-r--r--tests/installer-steps/test_ensure_nvme_early_module.py95
-rw-r--r--tests/installer-steps/test_grub_cmdline.py121
-rw-r--r--tests/installer-steps/test_idempotency_cluster.py119
-rw-r--r--tests/installer-steps/test_install_executable.py66
-rw-r--r--tests/installer-steps/test_pacman_hook_order.py72
-rw-r--r--tests/installer-steps/test_replace_sudoers_pacnew.py76
-rw-r--r--tests/installer-steps/test_required_software.py58
-rw-r--r--tests/installer-steps/test_run_step.py92
-rw-r--r--tests/installer-steps/test_set_user_password.py56
-rw-r--r--tests/installer-steps/test_validate_yesno.py57
11 files changed, 896 insertions, 10 deletions
diff --git a/tests/installer-steps/test_check_disk_space.py b/tests/installer-steps/test_check_disk_space.py
new file mode 100644
index 0000000..deec6d9
--- /dev/null
+++ b/tests/installer-steps/test_check_disk_space.py
@@ -0,0 +1,94 @@
+"""Test the disk-space pre-flight gate.
+
+Two bugs the extracted check_disk_space fixes:
+
+1. `df /` wraps to two lines when the source device name is long (common on a
+ live ISO / device-mapper root). The old `df / | awk 'NR==2 {print $4}'`
+ then reads the device-name line, gets an empty $4, and wrongly aborts on a
+ disk with plenty of space. `df -P /` forces POSIX single-line output.
+2. The old code truncated KB -> GB with integer division before comparing,
+ biasing the gate against the user near the threshold. The check now compares
+ available KB against the minimum in KB directly.
+
+Method: sed-extract check_disk_space from the real `archsetup`, run it with a
+fake df (controlled output, and wrapped output for the plain-df regression) and
+assert the gate passes/aborts correctly.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_check_disk_space
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+# Fake df: single, correct POSIX line for `df -P`; a WRAPPED two-line body for
+# plain `df`. If check_disk_space ever reverts to plain `df`, the wrapped body
+# yields an empty Available field and the ample-space test fails -- that is the
+# regression guard for bug 1.
+FAKE_DF = textwrap.dedent("""\
+ df() {{
+ if [ "$1" = "-P" ]; then
+ printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on"
+ printf '%s\\n' "/dev/mapper/root {avail} 100 {avail} 1% /"
+ else
+ printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on"
+ printf '%s\\n' "/dev/mapper/a-very-long-device-mapper-name-that-wraps"
+ printf '%s\\n' " {avail} 100 {avail} 1% /"
+ fi
+ }}
+""")
+
+
+def run_check(available_kb, min_gb=20):
+ """Run check_disk_space with fake df reporting available_kb free."""
+ script = textwrap.dedent(f"""\
+ min_disk_space_gb={min_gb}
+ {FAKE_DF.format(avail=available_kb)}
+ source <(sed -n '/^check_disk_space() {{/,/^}}/p' "{ARCHSETUP}")
+ check_disk_space
+ echo "REACHED-END"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class CheckDiskSpace(unittest.TestCase):
+ # min 20GB -> 20 * 1024 * 1024 = 20971520 KB
+ MIN_KB = 20 * 1024 * 1024
+
+ def test_ample_space_passes(self):
+ # ~95GB free; also the wrapped-df regression guard (plain df would
+ # yield an empty Available and wrongly abort).
+ r = run_check(99000000)
+ self.assertIn("REACHED-END", r.stdout)
+ self.assertIn("[OK] Disk space", r.stdout)
+
+ def test_insufficient_space_aborts(self):
+ r = run_check(5 * 1024 * 1024) # 5GB
+ self.assertIn("Insufficient disk space", r.stdout)
+ self.assertNotIn("REACHED-END", r.stdout)
+
+ def test_boundary_exact_minimum_passes(self):
+ r = run_check(self.MIN_KB) # exactly 20GB in KB
+ self.assertIn("REACHED-END", r.stdout)
+
+ def test_boundary_one_kb_under_minimum_aborts(self):
+ r = run_check(self.MIN_KB - 1)
+ self.assertIn("Insufficient disk space", r.stdout)
+ self.assertNotIn("REACHED-END", r.stdout)
+
+ def test_empty_df_output_aborts(self):
+ # df field unparseable -> treat as zero, abort rather than crash.
+ r = run_check("")
+ self.assertIn("Insufficient disk space", r.stdout)
+ self.assertNotIn("REACHED-END", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_ensure_nvme_early_module.py b/tests/installer-steps/test_ensure_nvme_early_module.py
new file mode 100644
index 0000000..8e3f036
--- /dev/null
+++ b/tests/installer-steps/test_ensure_nvme_early_module.py
@@ -0,0 +1,95 @@
+"""Test the nvme early-module step.
+
+Two bugs the extracted ensure_nvme_early_module fixes:
+
+1. The MODULES=(... nvme) edit was only compiled into the initramfs by a
+ mkinitcpio -P that ran behind `if ! is_zfs_root`, so on ZFS-root machines
+ the early-load hardening never landed. The step now rebuilds whenever it
+ changed the file, unconditionally.
+2. The already-present check grepped the whole file for "nvme", so a comment
+ or an unrelated token (nvme_tcp) anywhere in mkinitcpio.conf skipped the
+ edit. The check now looks for the nvme word on the MODULES line only.
+
+Method: sed-extract ensure_nvme_early_module from the real `archsetup`, point
+it at a temp mkinitcpio.conf, and fake has_nvme_drives/backup_system_file/
+mkinitcpio/display/error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_ensure_nvme_early_module
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(conf_body, has_nvme=True):
+ with tempfile.TemporaryDirectory() as d:
+ conf = os.path.join(d, "mkinitcpio.conf")
+ with open(conf, "w") as f:
+ f.write(conf_body)
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ backup_system_file() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ has_nvme_drives() {{ return {0 if has_nvme else 1}; }}
+ # stdout is redirected into $logfile at the call site, so the fake
+ # records its invocation in a side file instead.
+ mkinitcpio() {{ echo "MKINITCPIO $*" >> "{d}/mk.log"; }}
+ source <(sed -n '/^ensure_nvme_early_module() {{/,/^}}/p' "{ARCHSETUP}")
+ ensure_nvme_early_module "{conf}"
+ echo "RC=$?"
+ echo "CONF:[$(cat "{conf}")]"
+ [ -f "{d}/mk.log" ] && cat "{d}/mk.log"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class EnsureNvmeEarlyModule(unittest.TestCase):
+ def test_empty_modules_gets_nvme_and_rebuilds(self):
+ r = run("MODULES=()\nHOOKS=(base udev)\n")
+ self.assertIn("MODULES=(nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout,
+ "the initramfs must rebuild after the MODULES edit")
+
+ def test_populated_modules_appends_nvme_and_rebuilds(self):
+ r = run("MODULES=(btrfs)\n")
+ self.assertIn("MODULES=(btrfs nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout)
+
+ def test_nvme_already_present_no_edit_no_rebuild(self):
+ r = run("MODULES=(nvme)\n")
+ self.assertIn("MODULES=(nvme)", r.stdout)
+ self.assertNotIn("MODULES=(nvme nvme)", r.stdout)
+ self.assertNotIn("MKINITCPIO", r.stdout,
+ "an unchanged conf must not trigger a rebuild (resume idempotence)")
+
+ def test_nvme_mention_elsewhere_does_not_skip_the_edit(self):
+ # The old whole-file grep skipped the edit when any line mentioned
+ # nvme; a comment must not mask a missing MODULES entry.
+ r = run("# early nvme notes: nvme_load=YES\nMODULES=(btrfs)\n")
+ self.assertIn("MODULES=(btrfs nvme)", r.stdout)
+ self.assertIn("MKINITCPIO -P", r.stdout)
+
+ def test_nvme_tcp_module_does_not_count_as_nvme(self):
+ r = run("MODULES=(nvme_tcp)\n")
+ self.assertIn("MODULES=(nvme_tcp nvme)", r.stdout)
+
+ def test_no_nvme_drives_is_a_noop(self):
+ r = run("MODULES=()\n", has_nvme=False)
+ self.assertIn("RC=0", r.stdout)
+ self.assertIn("CONF:[MODULES=()", r.stdout)
+ self.assertNotIn("MKINITCPIO", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py
new file mode 100644
index 0000000..8c0b5b0
--- /dev/null
+++ b/tests/installer-steps/test_grub_cmdline.py
@@ -0,0 +1,121 @@
+"""Test the GRUB cmdline merge.
+
+Bug (P2): configure_grub rewrote the whole GRUB_CMDLINE_LINUX_DEFAULT line
+with a fixed string. A base install that had set cryptdevice=/resume=/zfs=
+(or any other boot-critical token) lost it, and the following grub-mkconfig
+baked an unbootable config.
+
+The fix is a merge: every pre-existing token survives, archsetup's tokens are
+added, and where both set the same key archsetup's value wins. A safety
+assert refuses to write if any existing token's key would vanish.
+
+Method: sed-extract merge_grub_cmdline (pure) and update_grub_cmdline
+(file-level, run against temp grub files with a fake error_warn).
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_grub_cmdline
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+EXTRACT = (
+ "source <(sed -n '/^merge_grub_cmdline() {{/,/^}}/p;"
+ "/^update_grub_cmdline() {{/,/^}}/p' \"{a}\")"
+).format(a=ARCHSETUP)
+
+
+def run(body):
+ script = f"logfile=/dev/null\nerror_warn() {{ echo \"WARN: $1\"; return 1; }}\n{EXTRACT}\n{body}\n"
+ return subprocess.run(["bash", "-c", script],
+ capture_output=True, text=True, timeout=10)
+
+
+def merge(existing, desired):
+ r = run(f'merge_grub_cmdline "{existing}" "{desired}"')
+ return r.stdout.strip()
+
+
+class MergeGrubCmdline(unittest.TestCase):
+ DESIRED = "rw loglevel=2 quiet splash"
+
+ def test_empty_existing_yields_desired(self):
+ self.assertEqual(merge("", self.DESIRED), self.DESIRED)
+
+ def test_boot_critical_tokens_survive(self):
+ out = merge("cryptdevice=UUID=abc:root resume=/dev/nvme0n1p3 root=/dev/mapper/root",
+ self.DESIRED)
+ for tok in ("cryptdevice=UUID=abc:root", "resume=/dev/nvme0n1p3",
+ "root=/dev/mapper/root", "loglevel=2", "quiet", "splash"):
+ self.assertIn(tok, out.split(), f"{tok} missing from: {out}")
+
+ def test_same_key_desired_value_wins_once(self):
+ out = merge("loglevel=7 quiet", self.DESIRED).split()
+ self.assertIn("loglevel=2", out)
+ self.assertNotIn("loglevel=7", out)
+ self.assertEqual(out.count("loglevel=2"), 1)
+ self.assertEqual(out.count("quiet"), 1)
+
+ def test_zfs_token_survives(self):
+ out = merge("zfs=zroot/ROOT/default", self.DESIRED).split()
+ self.assertIn("zfs=zroot/ROOT/default", out)
+
+
+class UpdateGrubCmdline(unittest.TestCase):
+ def run_update(self, grub_body):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "grub")
+ with open(path, "w") as f:
+ f.write(grub_body)
+ r = run(f'update_grub_cmdline "{path}"; echo "RC=$?"')
+ with open(path) as f:
+ return r, f.read()
+
+ def line(self, content):
+ return [ln for ln in content.splitlines()
+ if ln.startswith('GRUB_CMDLINE_LINUX_DEFAULT=')]
+
+ def test_existing_tokens_preserved_in_file(self):
+ _, content = self.run_update(textwrap.dedent("""\
+ GRUB_TIMEOUT=5
+ GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 cryptdevice=UUID=abc:root resume=/dev/sda2"
+ GRUB_DISABLE_RECOVERY=true
+ """))
+ lines = self.line(content)
+ self.assertEqual(len(lines), 1)
+ val = lines[0]
+ self.assertIn("cryptdevice=UUID=abc:root", val)
+ self.assertIn("resume=/dev/sda2", val)
+ self.assertIn("loglevel=2", val) # archsetup's value wins
+ self.assertNotIn("loglevel=3", val)
+ self.assertIn("quiet", val)
+ # other lines untouched
+ self.assertIn("GRUB_TIMEOUT=5", content)
+ self.assertIn("GRUB_DISABLE_RECOVERY=true", content)
+
+ def test_quoting_stays_a_single_pair(self):
+ _, content = self.run_update('GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n')
+ val = self.line(content)[0]
+ self.assertEqual(val.count('"'), 2)
+ self.assertRegex(val, r'^GRUB_CMDLINE_LINUX_DEFAULT="[^"]*"$')
+
+ def test_commented_line_gets_active_line_appended(self):
+ _, content = self.run_update('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n')
+ self.assertEqual(len(self.line(content)), 1)
+ self.assertIn('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"', content)
+
+ def test_idempotent_on_rerun(self):
+ body = 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=abc:root"\n'
+ _, once = self.run_update(body)
+ _, twice = self.run_update(once)
+ self.assertEqual(once, twice)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py
new file mode 100644
index 0000000..ecb279d
--- /dev/null
+++ b/tests/installer-steps/test_idempotency_cluster.py
@@ -0,0 +1,119 @@
+"""Test the resume-idempotency cluster: three extracted installer helpers.
+
+Each fix targets a step that misbehaves on a resume re-run or with unexpected
+system state:
+
+- crontab_append_once: the log-cleanup cron line was appended unconditionally,
+ so a resume re-run of essential_services stacked a duplicate line each time.
+ The helper appends only when no existing line matches.
+- zfs_scrub_timer_units: the scrub timer used `zpool list | head -1`, which
+ picks an arbitrary pool when several exist and yields the malformed unit
+ `zfs-scrub-weekly@.timer` when none do. The helper emits one unit per pool
+ and nothing for no pools.
+- enable_user_service: gamemode was enabled via `systemctl --user enable`,
+ which the script itself documents fails at install time (no user bus). The
+ helper creates the wants symlink directly, the pattern syncthing already uses.
+
+Method: sed-extract each helper from the real `archsetup` and drive it with
+plain stdin/args (the first two are pure) or a temp HOME (the third).
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_idempotency_cluster
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+ME = os.environ.get("USER") or __import__("getpass").getuser()
+
+
+def extract(func):
+ return (
+ "source <(sed -n '/^{f}() {{/,/^}}/p' \"{a}\")".format(f=func, a=ARCHSETUP)
+ )
+
+
+def run(func, body, stdin=None):
+ script = f"{extract(func)}\n{body}\n"
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ input=stdin,
+ )
+
+
+class CrontabAppendOnce(unittest.TestCase):
+ def call(self, existing, match, line):
+ body = f'crontab_append_once {match!r} {line!r}'
+ return run("crontab_append_once", body, stdin=existing).stdout
+
+ def test_appends_when_absent(self):
+ out = self.call("", "log-cleanup", "0 12 * * * run-cleanup")
+ self.assertIn("0 12 * * * run-cleanup", out)
+
+ def test_preserves_existing_and_appends(self):
+ out = self.call("0 0 * * * other-job\n", "log-cleanup", "0 12 * * * cleanup")
+ self.assertIn("other-job", out)
+ self.assertIn("0 12 * * * cleanup", out)
+
+ def test_does_not_duplicate_when_present(self):
+ existing = "0 12 * * * $HOME/.local/bin/cron/log-cleanup\n"
+ out = self.call(existing, "cron/log-cleanup", "0 12 * * * dup")
+ self.assertNotIn("0 12 * * * dup", out)
+ self.assertEqual(out.count("log-cleanup"), 1)
+
+
+class ZfsScrubTimerUnits(unittest.TestCase):
+ def units(self, pools_text):
+ out = run("zfs_scrub_timer_units", "zfs_scrub_timer_units", stdin=pools_text).stdout
+ return [ln for ln in out.splitlines() if ln]
+
+ def test_single_pool(self):
+ self.assertEqual(self.units("zroot\n"),
+ ["zfs-scrub-weekly@zroot.timer"])
+
+ def test_no_pool_yields_no_unit(self):
+ self.assertEqual(self.units(""), [])
+
+ def test_multiple_pools_each_get_a_unit(self):
+ self.assertEqual(
+ self.units("zroot\ntank\n"),
+ ["zfs-scrub-weekly@zroot.timer", "zfs-scrub-weekly@tank.timer"],
+ )
+
+ def test_blank_lines_are_skipped(self):
+ self.assertEqual(self.units("zroot\n\n"),
+ ["zfs-scrub-weekly@zroot.timer"])
+
+
+class EnableUserService(unittest.TestCase):
+ def test_creates_wants_symlink_in_user_config(self):
+ with tempfile.TemporaryDirectory() as home:
+ body = (
+ f'enable_user_service {ME!r} gamemoded.service '
+ f'/usr/lib/systemd/user/gamemoded.service {home!r}\n'
+ f'link="{home}/.config/systemd/user/default.target.wants/gamemoded.service"\n'
+ f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n'
+ f'echo "TARGET=$(readlink "$link")"'
+ )
+ out = run("enable_user_service", body).stdout
+ self.assertIn("LINK=yes", out)
+ self.assertIn("TARGET=/usr/lib/systemd/user/gamemoded.service", out)
+
+ def test_idempotent_on_rerun(self):
+ with tempfile.TemporaryDirectory() as home:
+ svc = "/usr/lib/systemd/user/gamemoded.service"
+ one = (
+ f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n'
+ f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n'
+ f'echo "RC=$?"'
+ )
+ out = run("enable_user_service", one).stdout
+ self.assertIn("RC=0", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_install_executable.py b/tests/installer-steps/test_install_executable.py
new file mode 100644
index 0000000..6a2ecfd
--- /dev/null
+++ b/tests/installer-steps/test_install_executable.py
@@ -0,0 +1,66 @@
+"""Test the guarded executable install helper.
+
+With `set -e` off, the installer's bare `cp script /usr/local/bin/...` followed
+by `chmod +x` failed silently when the source was missing or partial, leaving a
+systemd service with a dead ExecStart or a pacman hook pointing at a script that
+was never installed. install_executable guards the copy and the chmod, warns on
+failure, and skips the chmod when the copy did not land.
+
+Method: sed-extract install_executable from the real `archsetup` and run it
+against real temp files (no fakes needed) with a fake error_warn recorder.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_executable
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(src, dest):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^install_executable() {{/,/^}}/p' "{ARCHSETUP}")
+ install_executable "{src}" "{dest}"
+ echo "RC=$?"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class InstallExecutable(unittest.TestCase):
+ def test_copies_and_makes_executable(self):
+ with tempfile.TemporaryDirectory() as d:
+ src = os.path.join(d, "myscript")
+ dest = os.path.join(d, "bin", "myscript")
+ os.mkdir(os.path.join(d, "bin"))
+ with open(src, "w") as f:
+ f.write("#!/bin/sh\necho hi\n")
+ r = run(src, dest)
+ self.assertIn("RC=0", r.stdout)
+ self.assertTrue(os.path.exists(dest))
+ self.assertTrue(os.access(dest, os.X_OK), "dest must be executable")
+ self.assertNotIn("WARN", r.stdout)
+
+ def test_missing_source_warns_and_skips_chmod(self):
+ with tempfile.TemporaryDirectory() as d:
+ src = os.path.join(d, "does-not-exist")
+ dest = os.path.join(d, "bin", "myscript")
+ os.mkdir(os.path.join(d, "bin"))
+ r = run(src, dest)
+ self.assertIn("WARN: copying", r.stdout,
+ "a failed copy must warn, not silently continue")
+ self.assertNotIn("RC=0", r.stdout, "helper returns non-zero on copy failure")
+ self.assertFalse(os.path.exists(dest))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py
index 49acdbe..45058dd 100644
--- a/tests/installer-steps/test_pacman_hook_order.py
+++ b/tests/installer-steps/test_pacman_hook_order.py
@@ -1,26 +1,78 @@
-"""Regression tests for the pacman hook ordering safety invariant."""
+"""Regression tests for the pacman hook ordering safety invariant.
+
+pacman runs hooks in filename sort order, so the safety hooks archsetup
+installs (the pre-pacman ZFS snapshot and the live-update guard, both
+PreTransaction) must carry names that sort before mkinitcpio's stock
+60-mkinitcpio-remove.hook. Otherwise a blocked transaction can remove the
+current initramfs before the guard aborts, or after the snapshot window.
+
+The earlier version of this test asserted the ordering by comparing two string
+literals to each other, which is constant-true and never looked at the source.
+These tests extract the hook filenames the installer actually writes and
+compare those, so a rename in the source flows into the assertion.
+"""
import os
+import re
import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+MKINITCPIO_REMOVE = "60-mkinitcpio-remove.hook" # stock mkinitcpio hook name
+
+WRITE_RE = re.compile(r">\s*/etc/pacman\.d/hooks/(\S+\.hook)\b")
+
+
+def written_hooks(text):
+ """Hook filenames the source writes (redirections into the hooks dir)."""
+ names = []
+ for line in text.splitlines():
+ if "rm -f" in line:
+ continue
+ m = WRITE_RE.search(line)
+ if m:
+ names.append(m.group(1))
+ return names
+
class PacmanHookOrderTests(unittest.TestCase):
- def test_safety_hooks_precede_mkinitcpio_removal(self):
+ @classmethod
+ def setUpClass(cls):
with open(ARCHSETUP, encoding="utf-8") as source:
- text = source.read()
+ cls.text = source.read()
+ cls.hooks = written_hooks(cls.text)
+ def hook_named(self, suffix):
+ matches = [h for h in self.hooks if h.endswith(suffix)]
+ self.assertEqual(
+ len(matches), 1,
+ f"expected exactly one written hook ending {suffix}, got {matches}")
+ return matches[0]
+
+ def test_safety_hooks_sort_before_mkinitcpio_removal(self):
+ # The invariant pacman actually enforces: filename sort order.
+ for suffix in ("zfs-snapshot.hook", "hypr-live-update-guard.hook"):
+ name = self.hook_named(suffix)
+ self.assertLess(
+ name, MKINITCPIO_REMOVE,
+ f"{name} must sort before {MKINITCPIO_REMOVE} or the guard "
+ "runs after the initramfs removal")
+
+ def test_every_written_hook_has_an_ordering_prefix(self):
+ # Ordering is deliberate; a hook without a numeric prefix sorts by
+ # accident of its first letter.
+ self.assertTrue(self.hooks, "extraction found no written hooks")
+ for name in self.hooks:
+ self.assertRegex(name, r"^\d{2}-",
+ f"{name} lacks the two-digit ordering prefix")
+
+ def test_stale_unprefixed_hooks_are_cleaned_up(self):
+ # Migration from installs made before the numeric prefixes.
+ self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", self.text)
self.assertIn(
- "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text)
- self.assertIn(
- "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text)
- self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook")
- self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook")
- self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text)
- self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text)
+ "rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", self.text)
if __name__ == "__main__":
diff --git a/tests/installer-steps/test_replace_sudoers_pacnew.py b/tests/installer-steps/test_replace_sudoers_pacnew.py
new file mode 100644
index 0000000..8bfa914
--- /dev/null
+++ b/tests/installer-steps/test_replace_sudoers_pacnew.py
@@ -0,0 +1,76 @@
+"""Test the guarded sudoers.pacnew replacement.
+
+Bug (Minor, hard consequence): the installer did
+ [ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers
+with no validation. A malformed pacnew replaces /etc/sudoers with a file sudo
+refuses to parse, locking out privilege escalation right before the NOPASSWD
+rule is appended. replace_sudoers_pacnew now runs `visudo -cf` on the pacnew
+first and only copies a file that validates; a failure warns (non-fatal) and
+leaves the working sudoers in place.
+
+Method: sed-extract replace_sudoers_pacnew from the real `archsetup`, pass a
+temp pacnew + temp target (the function's positional-arg testability seam), and
+drive it with a fake visudo (controlled validation result) and a fake
+error_warn recorder.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_replace_sudoers_pacnew
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(*, pacnew_exists, visudo_rc):
+ """Run replace_sudoers_pacnew with a temp pacnew/target and fake visudo."""
+ with tempfile.TemporaryDirectory() as d:
+ pacnew = os.path.join(d, "sudoers.pacnew")
+ target = os.path.join(d, "sudoers")
+ with open(target, "w") as f:
+ f.write("ORIGINAL\n")
+ if pacnew_exists:
+ with open(pacnew, "w") as f:
+ f.write("NEW\n")
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ display() {{ :; }}
+ visudo() {{ return {visudo_rc}; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^replace_sudoers_pacnew() {{/,/^}}/p' "{ARCHSETUP}")
+ replace_sudoers_pacnew "{pacnew}" "{target}"
+ echo "RC=$?"
+ echo "TARGET=[$(cat "{target}")]"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class ReplaceSudoersPacnew(unittest.TestCase):
+ def test_valid_pacnew_is_copied(self):
+ r = run(pacnew_exists=True, visudo_rc=0)
+ self.assertIn("TARGET=[NEW]", r.stdout)
+ self.assertNotIn("WARN", r.stdout)
+
+ def test_invalid_pacnew_warns_and_keeps_original(self):
+ r = run(pacnew_exists=True, visudo_rc=1)
+ self.assertIn("WARN", r.stdout,
+ "a pacnew that fails visudo must warn, not clobber sudoers")
+ self.assertIn("TARGET=[ORIGINAL]", r.stdout,
+ "the working sudoers must survive a malformed pacnew")
+
+ def test_no_pacnew_is_a_noop(self):
+ r = run(pacnew_exists=False, visudo_rc=0)
+ self.assertIn("RC=0", r.stdout)
+ self.assertIn("TARGET=[ORIGINAL]", r.stdout)
+ self.assertNotIn("WARN", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_required_software.py b/tests/installer-steps/test_required_software.py
new file mode 100644
index 0000000..85b01d8
--- /dev/null
+++ b/tests/installer-steps/test_required_software.py
@@ -0,0 +1,58 @@
+"""Test that install_required_software declares the expected base packages.
+
+inetutils provides /usr/bin/ftp, which TRAMP's /ftp: method (ange-ftp) shells
+out to; Arch ships no command-line ftp client by default. It must be in the
+base install so every machine gets it (Craig's dirvish config has an FTP
+quick-access entry that depends on it).
+
+Method mirrors test_orchestrators: sed-extract install_required_software from
+the real `archsetup`, stub pacman_install as a recorder that echoes the package
+name, run, and assert membership.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_required_software
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def required_packages():
+ """Return (exit_code, [package, ...]) declared by install_required_software."""
+ script = textwrap.dedent(f"""\
+ display() {{ :; }}
+ pacman_install() {{ echo "$1"; }}
+ source <(sed -n '/^install_required_software() {{/,/^}}/p' "{ARCHSETUP}")
+ install_required_software
+ """)
+ result = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+ return result.returncode, result.stdout.split()
+
+
+class RequiredSoftware(unittest.TestCase):
+ def test_installs_inetutils_for_ftp(self):
+ rc, pkgs = required_packages()
+ self.assertEqual(rc, 0)
+ self.assertIn(
+ "inetutils", pkgs,
+ "inetutils (provides /usr/bin/ftp for TRAMP's /ftp: method) must be "
+ "in the base install",
+ )
+
+ def test_core_base_packages_present(self):
+ # Guard a few load-bearing entries so a bad edit to the list is caught.
+ rc, pkgs = required_packages()
+ self.assertEqual(rc, 0)
+ for p in ("base-devel", "git", "stow", "openssh", "curl"):
+ self.assertIn(p, pkgs, f"{p} dropped from the base install")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_run_step.py b/tests/installer-steps/test_run_step.py
new file mode 100644
index 0000000..994e8c5
--- /dev/null
+++ b/tests/installer-steps/test_run_step.py
@@ -0,0 +1,92 @@
+"""Test run_step's state-marker and scope behavior.
+
+Bug (Major): run_step marked a step complete only when its function returned 0.
+But error_fatal exits the script outright, so a step function that *returns* has
+already survived every fatal check -- a non-zero return can only come from a
+trailing non-fatal warning (error_warn returns 1). The old code read that as a
+step failure and withheld the state marker, so the step re-ran on every resume.
+run_step now records the marker whenever the function returns.
+
+Bug (Minor): run_step's step_name/step_func leaked to global scope.
+
+Method: sed-extract run_step + step_completed + mark_complete from the real
+`archsetup`, point state_dir at a temp dir, and drive it with fake step
+functions.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_run_step
+"""
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+EXTRACT = (
+ "source <(sed -n '/^run_step() {{/,/^}}/p;"
+ "/^step_completed() {{/,/^}}/p;"
+ "/^mark_complete() {{/,/^}}/p' \"{archsetup}\")"
+).format(archsetup=ARCHSETUP)
+
+
+def run(step_body, pre_marked=False):
+ """Run run_step against a fake step function whose body is step_body."""
+ with tempfile.TemporaryDirectory() as state_dir:
+ marker_setup = (
+ f'mkdir -p "{state_dir}"; echo pre > "{state_dir}/mystep"\n'
+ if pre_marked else ""
+ )
+ script = textwrap.dedent(f"""\
+ state_dir="{state_dir}"
+ {marker_setup}
+ mystep() {{
+ {step_body}
+ }}
+ {EXTRACT}
+ run_step "mystep" "mystep"
+ rc=$?
+ echo "RUN_STEP_RC=$rc"
+ # marker present?
+ [ -f "$state_dir/mystep" ] && echo "MARKED=yes" || echo "MARKED=no"
+ # locals must not leak
+ echo "LEAK_step_name=[${{step_name:-}}]"
+ echo "LEAK_step_func=[${{step_func:-}}]"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class RunStep(unittest.TestCase):
+ def test_success_marks_complete(self):
+ r = run("return 0")
+ self.assertIn("RUN_STEP_RC=0", r.stdout)
+ self.assertIn("MARKED=yes", r.stdout)
+
+ def test_nonfatal_warning_return_still_marks_complete(self):
+ # The bug fix: a step ending in a non-fatal warning returns 1 but has
+ # done its work; it must be marked so resume does not re-run it.
+ r = run("return 1")
+ self.assertIn("MARKED=yes", r.stdout,
+ "a returned-non-zero step must still record its marker")
+ self.assertIn("RUN_STEP_RC=0", r.stdout,
+ "run_step returns 0 so the main loop treats it as handled")
+
+ def test_already_completed_skips_and_does_not_rerun(self):
+ # Body would create a sentinel if it ran; pre-marked step must skip it.
+ r = run("echo RAN-BODY", pre_marked=True)
+ self.assertIn("Skipping", r.stdout)
+ self.assertNotIn("RAN-BODY", r.stdout)
+
+ def test_locals_do_not_leak_to_global_scope(self):
+ r = run("return 0")
+ self.assertIn("LEAK_step_name=[]", r.stdout)
+ self.assertIn("LEAK_step_func=[]", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_set_user_password.py b/tests/installer-steps/test_set_user_password.py
new file mode 100644
index 0000000..9f21b3a
--- /dev/null
+++ b/tests/installer-steps/test_set_user_password.py
@@ -0,0 +1,56 @@
+"""Test that the primary user's password is set under a guard.
+
+archsetup runs with `set -e` OFF (line 21), so an unguarded `chpasswd` that
+fails silently leaves the primary user with no password and no log entry -- an
+unloggable account on a fresh install. set_user_password guards the chpasswd
+with error_fatal so a failure aborts loudly instead of passing unnoticed.
+
+Method: sed-extract set_user_password from the real `archsetup`, run it with a
+fake chpasswd (controlled exit) and a fake error_fatal recorder. Assert the
+guard fires on failure and stays quiet on success.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_set_user_password
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run_set_password(chpasswd_rc):
+ """Run set_user_password with a fake chpasswd exiting chpasswd_rc."""
+ script = textwrap.dedent(f"""\
+ password="hunter2"
+ logfile=/dev/null
+ display() {{ :; }}
+ chpasswd() {{ cat >/dev/null; return {chpasswd_rc}; }}
+ error_fatal() {{ echo "FATAL: $1"; exit 9; }}
+ source <(sed -n '/^set_user_password() {{/,/^}}/p' "{ARCHSETUP}")
+ set_user_password "alice"
+ echo "REACHED-END"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class SetUserPassword(unittest.TestCase):
+ def test_success_does_not_abort(self):
+ r = run_set_password(0)
+ self.assertIn("REACHED-END", r.stdout)
+ self.assertNotIn("FATAL", r.stdout)
+
+ def test_failure_aborts_with_error_fatal(self):
+ r = run_set_password(1)
+ self.assertIn("FATAL: setting password for alice", r.stdout,
+ "a chpasswd failure must abort via error_fatal")
+ self.assertNotIn("REACHED-END", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_validate_yesno.py b/tests/installer-steps/test_validate_yesno.py
new file mode 100644
index 0000000..da78fc5
--- /dev/null
+++ b/tests/installer-steps/test_validate_yesno.py
@@ -0,0 +1,57 @@
+"""Test the validate_yesno config-validation helper.
+
+The installer had four near-identical blocks validating that AUTOLOGIN,
+NO_GPU_DRIVERS, INSTALL_CLAUDE_CODE, and INSTALL_DEVICE_UDEV_RULES are empty or
+exactly yes/no. validate_yesno collapses them into one testable helper: empty
+passes (the default), yes/no pass, anything else fails with a named error.
+
+Method: sed-extract validate_yesno from the real `archsetup` and drive it with
+plain args.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_validate_yesno
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(name, value):
+ script = textwrap.dedent(f"""\
+ source <(sed -n '/^validate_yesno() {{/,/^}}/p' "{ARCHSETUP}")
+ validate_yesno {name!r} {value!r}
+ echo "RC=$?"
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+class ValidateYesno(unittest.TestCase):
+ def test_yes_passes(self):
+ self.assertIn("RC=0", run("AUTOLOGIN", "yes").stdout)
+
+ def test_no_passes(self):
+ self.assertIn("RC=0", run("AUTOLOGIN", "no").stdout)
+
+ def test_empty_passes(self):
+ self.assertIn("RC=0", run("AUTOLOGIN", "").stdout)
+
+ def test_other_value_fails_with_named_error(self):
+ r = run("NO_GPU_DRIVERS", "maybe")
+ self.assertNotIn("RC=0", r.stdout)
+ self.assertIn("NO_GPU_DRIVERS", r.stderr)
+ self.assertIn("maybe", r.stderr)
+
+ def test_capitalized_yes_fails(self):
+ # The values are compared exactly; "Yes" is not accepted.
+ self.assertNotIn("RC=0", run("AUTOLOGIN", "Yes").stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()