diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-08 04:55:52 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-09 11:47:36 -0500 |
| commit | fca837883b0b79793ea81d223d6e08a8ee2b2248 (patch) | |
| tree | 2c9deff0b93ad0b42842fbe7ba30a4f28732a894 /tests | |
| parent | fc3a8508561114177a67bbdb6802bdcf1df7683e (diff) | |
| download | archsetup-fca837883b0b79793ea81d223d6e08a8ee2b2248.tar.gz archsetup-fca837883b0b79793ea81d223d6e08a8ee2b2248.zip | |
feat: automate microcode, TLP radio enable, and ZFS tmp.mount mask
The 2026-04 velox setup left three manual fixes behind. All three now happen at install time.
- The TLP config sets DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi". systemd-rfkill is masked on laptops because it fights TLP, so TLP is the only thing left that can restore radio state. Without this a fresh install can boot with both radios soft-blocked.
- mask_tmp_mount_for_zfs masks systemd's tmp.mount when the pool carries a dataset mounted at /tmp, so tmpfs can't shadow the dataset and break systemd-tmpfiles.
- install_cpu_microcode installs intel-ucode or amd-ucode by vendor_id, first in boot_ux so grub-mkconfig and mkinitcpio's microcode hook both see the ucode image.
New tests cover each function across normal, boundary, and error cases. The boot_ux and snapshot-dispatch sequence pins include the new calls.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/installer-steps/test_configure_tlp_power.py | 110 | ||||
| -rw-r--r-- | tests/installer-steps/test_install_cpu_microcode.py | 110 | ||||
| -rw-r--r-- | tests/installer-steps/test_mask_tmp_mount_for_zfs.py | 109 | ||||
| -rw-r--r-- | tests/installer-steps/test_orchestrators.py | 14 |
4 files changed, 337 insertions, 6 deletions
diff --git a/tests/installer-steps/test_configure_tlp_power.py b/tests/installer-steps/test_configure_tlp_power.py new file mode 100644 index 0000000..c88e0c2 --- /dev/null +++ b/tests/installer-steps/test_configure_tlp_power.py @@ -0,0 +1,110 @@ +"""Test configure_tlp_power's radio-enable line and laptop gating. + +systemd-rfkill is masked on laptops because it fights TLP's radio handling — +which means nothing restores radio state at boot unless TLP is told to. The +velox 2026-04-10 setup found wifi and bluetooth soft-blocked on first boot for +exactly this reason. The conf written here must carry +DEVICES_TO_ENABLE_ON_STARTUP so a fresh install comes up with radios on. + +Method: sed-extract configure_tlp_power from the real `archsetup`, point it at +a temp tlp.d dir and a temp power-supply dir, and fake pacman_install / +run_task / display / error_warn / systemctl. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_configure_tlp_power +""" + +import os +import stat +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(battery=True, bat_name="BAT0", unwritable_tlpd=False): + with tempfile.TemporaryDirectory() as d: + psdir = os.path.join(d, "power_supply") + os.makedirs(psdir) + if battery: + open(os.path.join(psdir, bat_name), "w").close() + tlpd = os.path.join(d, "tlp.d") + if unwritable_tlpd: + os.makedirs(tlpd) + os.chmod(tlpd, stat.S_IRUSR | stat.S_IXUSR) + # The real mask call redirects stdout into $logfile, so the fake + # systemctl records to a side file the test reads back instead. + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + pacman_install() {{ echo "INSTALL: $1"; }} + run_task() {{ echo "TASK: $1"; }} + systemctl() {{ echo "SYSTEMCTL: $*" >> "{d}/systemctl.log"; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^configure_tlp_power() {{/,/^}}/p' "{ARCHSETUP}") + configure_tlp_power "{tlpd}" "{psdir}" + echo "RC=$?" + echo "CONF:[$(cat "{tlpd}/01-custom.conf" 2>/dev/null)]" + [ -f "{d}/systemctl.log" ] && cat "{d}/systemctl.log" + exit 0 + """) + r = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + if unwritable_tlpd: + os.chmod(tlpd, stat.S_IRWXU) + return r + + +class ConfigureTlpPower(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_laptop_gets_tlp_with_radio_enable_line(self): + r = run(battery=True) + self.assertIn("INSTALL: tlp", r.stdout) + conf = r.stdout.split("CONF:[")[1] + self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"', conf, + "radios must be re-enabled at boot: systemd-rfkill is " + "masked, so TLP is the only thing that can do it") + self.assertIn("CPU_ENERGY_PERF_POLICY_ON_AC", conf) + self.assertIn("SYSTEMCTL: mask systemd-rfkill.service systemd-rfkill.socket", + r.stdout) + self.assertIn("TASK: enabling TLP service", r.stdout) + + def test_radio_line_is_active_not_commented(self): + r = run(battery=True) + conf = r.stdout.split("CONF:[")[1].split("]")[0] + for line in conf.splitlines(): + if "DEVICES_TO_ENABLE_ON_STARTUP" in line: + self.assertFalse(line.lstrip().startswith("#"), + "the radio-enable line must not be commented out") + break + else: + self.fail("DEVICES_TO_ENABLE_ON_STARTUP line missing from conf") + + # ---------------------------------------------------------- boundary ---- + def test_desktop_without_battery_is_a_no_op(self): + r = run(battery=False) + self.assertNotIn("INSTALL:", r.stdout) + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertIn("CONF:[]", r.stdout, "no conf may be written on a desktop") + + def test_second_battery_index_still_counts_as_laptop(self): + r = run(battery=True, bat_name="BAT1") + self.assertIn("INSTALL: tlp", r.stdout) + self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP', r.stdout) + + # ------------------------------------------------------------- error ---- + @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits") + def test_unwritable_tlpd_warns_and_does_not_crash(self): + r = run(battery=True, unwritable_tlpd=True) + self.assertIn("WARN:", r.stdout, + "a failed conf write must surface through error_warn") + self.assertEqual(r.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_install_cpu_microcode.py b/tests/installer-steps/test_install_cpu_microcode.py new file mode 100644 index 0000000..20f1820 --- /dev/null +++ b/tests/installer-steps/test_install_cpu_microcode.py @@ -0,0 +1,110 @@ +"""Test install_cpu_microcode — vendor-detected microcode package install. + +archsetup never installed intel-ucode/amd-ucode (found on velox 2026-04-10: +CPU running old microcode, hand-fixed). The step reads vendor_id from +/proc/cpuinfo and installs the matching package. It must run before +configure_grub in boot_ux: grub-mkconfig detects /boot/<vendor>-ucode.img for +its initrd lines, and mkinitcpio's microcode hook embeds it, so the package +has to exist before either generates. + +Method: sed-extract install_cpu_microcode from the real `archsetup`, point it +at a fixture cpuinfo, fake pacman_install / display / error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_cpu_microcode +""" + +import os +import re +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") + +INTEL = "vendor_id\t: GenuineIntel\n" +AMD = "vendor_id\t: AuthenticAMD\n" + + +def run(cpuinfo_body, missing=False): + with tempfile.TemporaryDirectory() as d: + cpuinfo = os.path.join(d, "cpuinfo") + if not missing: + with open(cpuinfo, "w") as f: + f.write(cpuinfo_body) + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + pacman_install() {{ echo "INSTALL: $1"; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_cpu_microcode() {{/,/^}}/p' "{ARCHSETUP}") + install_cpu_microcode "{cpuinfo}" + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class InstallCpuMicrocode(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_intel_vendor_installs_intel_ucode(self): + r = run(f"processor\t: 0\n{INTEL}model name\t: whatever\n") + self.assertIn("INSTALL: intel-ucode", r.stdout) + self.assertNotIn("amd-ucode", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_amd_vendor_installs_amd_ucode(self): + r = run(f"processor\t: 0\n{AMD}") + self.assertIn("INSTALL: amd-ucode", r.stdout) + self.assertNotIn("intel-ucode", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ---------------------------------------------------------- boundary ---- + def test_multicore_cpuinfo_installs_exactly_once(self): + # /proc/cpuinfo repeats vendor_id per logical CPU. + body = "".join(f"processor\t: {i}\n{AMD}\n" for i in range(16)) + r = run(body) + self.assertEqual(r.stdout.count("INSTALL:"), 1, + "one package install, not one per core") + + def test_space_separated_vendor_line_parses(self): + # Some kernels/arches pad with spaces rather than a tab. + r = run("vendor_id : GenuineIntel\n") + self.assertIn("INSTALL: intel-ucode", r.stdout) + + def test_vendor_id_substring_elsewhere_does_not_confuse(self): + # A flags line or model name mentioning a vendor string must not win + # over the real vendor_id line. + body = "model name\t: AuthenticAMD emulator\n" + INTEL + r = run(body) + self.assertIn("INSTALL: intel-ucode", r.stdout) + self.assertNotIn("amd-ucode", r.stdout) + + # ------------------------------------------------------------- error ---- + def test_unknown_vendor_warns_and_installs_nothing(self): + r = run("vendor_id\t: CentaurHauls\n") + self.assertNotIn("INSTALL:", r.stdout) + self.assertIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 1) + + def test_missing_cpuinfo_warns_and_installs_nothing(self): + r = run("", missing=True) + self.assertNotIn("INSTALL:", r.stdout) + self.assertIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 1) + self.assertEqual(r.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_mask_tmp_mount_for_zfs.py b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py new file mode 100644 index 0000000..f03086f --- /dev/null +++ b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py @@ -0,0 +1,109 @@ +"""Test mask_tmp_mount_for_zfs — the tmpfs-over-ZFS /tmp guard. + +When the pool carries a /tmp dataset, systemd's tmp.mount (tmpfs) races it at +boot; when tmpfs wins, the dataset is shadowed and systemd-tmpfiles-clean +fails repeatedly with "Protocol driver not attached" (velox, 2026-04-10). The +fix is masking tmp.mount so the dataset owns /tmp — but only when such a +dataset actually exists, and never on a machine without zfs at all. + +Method: sed-extract mask_tmp_mount_for_zfs from the real `archsetup`; fake +zfs / run_task / systemctl. The zfs-binary-absent case runs with a stripped +PATH containing only the tools the function itself needs. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_mask_tmp_mount_for_zfs +""" + +import os +import re +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(zfs_body=None, strip_zfs_from_path=False): + """zfs_body: bash body for a fake zfs function, or None for no fake. + strip_zfs_from_path: run with a minimal PATH that has grep/sed but no zfs. + """ + with tempfile.TemporaryDirectory() as d: + path_setup = "" + if strip_zfs_from_path: + fakebin = os.path.join(d, "bin") + os.makedirs(fakebin) + for tool in ("grep", "sed", "cat"): + src = subprocess.run(["bash", "-lc", f"command -v {tool}"], + capture_output=True, text=True).stdout.strip() + if src: + os.symlink(src, os.path.join(fakebin, tool)) + path_setup = f'PATH="{fakebin}"' + zfs_fake = f"zfs() {{ {zfs_body} }}" if zfs_body is not None else "" + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + run_task() {{ echo "TASK: $1"; shift; "$@"; }} + systemctl() {{ echo "SYSTEMCTL: $*"; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + {zfs_fake} + source <(sed -n '/^mask_tmp_mount_for_zfs() {{/,/^}}/p' "{ARCHSETUP}") + {path_setup} + mask_tmp_mount_for_zfs + echo "RC=$?" + exit 0 + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +def rc_of(r): + m = re.search(r"^RC=(\d+)$", r.stdout, re.M) + assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr) + return int(m.group(1)) + + +class MaskTmpMountForZfs(unittest.TestCase): + # ------------------------------------------------------------ normal ---- + def test_tmp_dataset_present_masks_tmp_mount(self): + r = run(zfs_body='printf "/\\n/home\\n/tmp\\n";') + self.assertIn("SYSTEMCTL: mask tmp.mount", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ---------------------------------------------------------- boundary ---- + def test_no_tmp_dataset_is_a_no_op(self): + r = run(zfs_body='printf "/\\n/home\\n/var\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_tmp_prefix_dataset_does_not_match(self): + # /tmp/scratch or /tmpfoo must not trigger the mask — exact match only. + r = run(zfs_body='printf "/\\n/tmp/scratch\\n/tmpfoo\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_legacy_and_none_mountpoints_do_not_match(self): + r = run(zfs_body='printf "legacy\\nnone\\n-\\n";') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + + # ------------------------------------------------------------- error ---- + def test_zfs_binary_absent_is_a_silent_no_op(self): + r = run(zfs_body=None, strip_zfs_from_path=True) + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertNotIn("WARN:", r.stdout) + self.assertEqual(rc_of(r), 0) + + def test_zfs_list_failure_is_a_no_op_not_a_crash(self): + r = run(zfs_body='echo "no pools available" >&2; return 1;') + self.assertNotIn("SYSTEMCTL:", r.stdout) + self.assertEqual(rc_of(r), 0) + self.assertNotIn("no pools available", r.stdout, + "zfs stderr noise must not leak into output") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py index a2b235b..395ec61 100644 --- a/tests/installer-steps/test_orchestrators.py +++ b/tests/installer-steps/test_orchestrators.py @@ -43,10 +43,10 @@ ORCHESTRATORS = { "install_devops_utilities", ], "boot_ux": [ - "tighten_efi_permissions", "add_nvme_early_module", - "configure_initramfs_hook", "configure_encrypted_autologin", - "configure_tlp_power", "trim_firmware", "configure_grub", - "configure_pre_pacman_snapshots", + "install_cpu_microcode", "tighten_efi_permissions", + "add_nvme_early_module", "configure_initramfs_hook", + "configure_encrypted_autologin", "configure_tlp_power", + "trim_firmware", "configure_grub", "configure_pre_pacman_snapshots", ], "user_customizations": [ "clone_user_repos", "stow_dotfiles", "prune_waybar_battery", @@ -88,7 +88,8 @@ class OrchestratorSequence(unittest.TestCase): class SnapshotDispatch(unittest.TestCase): """configure_snapshots branches on filesystem; pin each branch.""" - SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"] + SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots", + "mask_tmp_mount_for_zfs"] def test_zfs_root_runs_zfs_snapshots(self): result = run_orchestrator( @@ -96,7 +97,8 @@ class SnapshotDispatch(unittest.TestCase): extra_defs="is_zfs_root() { return 0; }\nis_btrfs_root() { return 1; }", ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.split(), ["configure_zfs_snapshots"]) + self.assertEqual(result.stdout.split(), + ["configure_zfs_snapshots", "mask_tmp_mount_for_zfs"]) def test_btrfs_root_runs_btrfs_snapshots(self): result = run_orchestrator( |
