aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup46
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py110
-rw-r--r--tests/installer-steps/test_install_cpu_microcode.py110
-rw-r--r--tests/installer-steps/test_mask_tmp_mount_for_zfs.py109
-rw-r--r--tests/installer-steps/test_orchestrators.py14
5 files changed, 380 insertions, 9 deletions
diff --git a/archsetup b/archsetup
index 96ef231..86f92a2 100755
--- a/archsetup
+++ b/archsetup
@@ -2002,6 +2002,7 @@ configure_snapshots() {
if is_zfs_root; then
configure_zfs_snapshots
+ mask_tmp_mount_for_zfs
elif is_btrfs_root; then
configure_btrfs_snapshots
else
@@ -2009,6 +2010,17 @@ configure_snapshots() {
fi
}
+mask_tmp_mount_for_zfs() {
+ # A ZFS /tmp dataset and systemd's tmp.mount (tmpfs) race at boot; when
+ # tmpfs wins it shadows the dataset and systemd-tmpfiles-clean fails
+ # repeatedly with "Protocol driver not attached" (velox, 2026-04-10).
+ # Mask tmp.mount so the dataset owns /tmp. No /tmp dataset, or no zfs at
+ # all → nothing to do.
+ command -v zfs >/dev/null 2>&1 || return 0
+ zfs list -H -o mountpoint 2>/dev/null | grep -qx '/tmp' || return 0
+ run_task "masking tmp.mount (ZFS owns /tmp)" systemctl mask tmp.mount
+}
+
configure_zfs_snapshots() {
# ZFS: Install sanoid for snapshot management
display "task" "ZFS detected - installing sanoid"
@@ -3190,6 +3202,7 @@ supplemental_software() {
boot_ux() {
action="Boot UX" && display "title" "$action"
+ install_cpu_microcode
tighten_efi_permissions
add_nvme_early_module
configure_initramfs_hook
@@ -3389,17 +3402,24 @@ configure_tlp_power() {
# TLP power management — laptops only (battery present). Manages wifi,
# USB, PCIe, and CPU power policy on AC/battery transitions. systemd-rfkill
# is masked per TLP's docs (it fights TLP's radio-state handling).
- if ls /sys/class/power_supply/BAT* &>/dev/null; then
+ # $1/$2 are the tlp.d dir and power-supply dir, defaulting to the system
+ # paths so tests can run against fixtures.
+ local tlpd="${1:-/etc/tlp.d}" psdir="${2:-/sys/class/power_supply}"
+ if ls "$psdir"/BAT* &>/dev/null; then
pacman_install tlp
action="writing TLP custom config" && display "task" "$action"
- mkdir -p /etc/tlp.d
- cat << 'EOF' > /etc/tlp.d/01-custom.conf
+ mkdir -p "$tlpd"
+ cat << 'EOF' > "$tlpd/01-custom.conf" || error_warn "writing TLP custom config" "$?"
# Custom TLP overrides (tuned on a Framework 13 Intel; generic for any laptop).
# Defaults are sane; these pin the CPU energy/perf split explicitly.
CPU_ENERGY_PERF_POLICY_ON_AC=balance_performance
CPU_ENERGY_PERF_POLICY_ON_BAT=power
PLATFORM_PROFILE_ON_AC=balanced
PLATFORM_PROFILE_ON_BAT=low-power
+# Radios on at boot: systemd-rfkill is masked (below), so TLP is the only
+# thing left that can restore radio state — without this a fresh install can
+# come up with wifi and bluetooth soft-blocked (velox, 2026-04-10).
+DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"
# Battery longevity: cap charge at 80% where the EC supports it.
# Off by default — uncomment (and match the BAT name) to enable.
#STOP_CHARGE_THRESH_BAT1=80
@@ -3411,6 +3431,26 @@ EOF
}
+install_cpu_microcode() {
+ # CPU microcode updates by vendor (found missing on velox, 2026-04-10:
+ # the CPU ran old microcode until intel-ucode was installed by hand).
+ # Must run before configure_grub: grub-mkconfig detects the installed
+ # /boot/<vendor>-ucode.img for its initrd lines, and mkinitcpio's
+ # microcode hook embeds it. $1 is the cpuinfo path, defaulting to the
+ # system's so tests can run against fixtures.
+ local cpuinfo="${1:-/proc/cpuinfo}" vendor pkg
+ vendor=$(awk -F': ' '/^vendor_id/ {print $2; exit}' "$cpuinfo" 2>/dev/null)
+ case "$vendor" in
+ GenuineIntel) pkg=intel-ucode ;;
+ AuthenticAMD) pkg=amd-ucode ;;
+ *)
+ error_warn "detecting CPU vendor for microcode (vendor_id: ${vendor:-unreadable})" 1
+ return 1
+ ;;
+ esac
+ pacman_install "$pkg"
+}
+
# Which GPU vendors does this machine physically have? Prints amd / intel /
# nvidia, one per line, for each vendor found. $1 and $2 are the DRM and PCI
# modalias globs, defaulting to the system paths so the probe runs against
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(