aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps')
-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_pacman_hook_order.py72
3 files changed, 278 insertions, 10 deletions
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_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__":