From 4c01e444eed40e240c9d112038e810c46ec5b59f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 3 Nov 2026 00:00:18 +0000 Subject: [PATCH] fix(linux-host): read Wi-Fi devices from sysfs without find The kernel probe ran `find -L` over /sys/class/net. Each entry there is a symlink to a device directory whose `subsystem` link points back at the class directory, so GNU find follows it, reports a file system loop and exits 1 after printing its matches, which failed the play on a real host. A shell glob over `/*/phy80211` has no traversal to loop and exits 0 with no output when no interface has a phy. The test sysfs is now a real directory tree with the same symlinks (class entry -> device directory holding phy80211 and a subsystem link back), read by the real shell, so a find that follows links fails it. --- .../tasks/wifi_powersave.yml | 11 +++++-- tests/unit/test_linux_host.py | 33 ++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/roles/github_runner_linux_host/tasks/wifi_powersave.yml b/roles/github_runner_linux_host/tasks/wifi_powersave.yml index 036e360a..f6c0bb8b 100644 --- a/roles/github_runner_linux_host/tasks/wifi_powersave.yml +++ b/roles/github_runner_linux_host/tasks/wifi_powersave.yml @@ -11,9 +11,14 @@ check_mode: false - name: Read the host's Wi-Fi devices from the kernel - # cfg80211 links every wireless network device to its phy as phy80211, whichever driver it has and whatever name it was given. + # cfg80211 links every wireless network device to its phy as phy80211, whichever driver it has and whatever name it was given. A glob, not find: each /sys/class/net/ is a symlink to a device directory whose subsystem link points back at /sys/class/net, so find -L reports a file system loop and exits 1 after printing its matches, and no find option makes its exit status mean "nothing wrong". An unmatched glob stays literal, which the -e test skips, so a host with no Wi-Fi device prints nothing and exits 0. ansible.builtin.command: - argv: [find, -L, "{{ github_runner_linux_host_sysfs_net_dir }}", -mindepth, "2", -maxdepth, "2", -name, phy80211] + argv: + - sh + - -c + - 'for phy in "$1"/*/phy80211; do [ -e "$phy" ] || continue; device=${phy%/phy80211}; printf "%s\n" "${device##*/}"; done' + - sh + - "{{ github_runner_linux_host_sysfs_net_dir }}" register: github_runner_linux_host_kernel_devices changed_when: false check_mode: false @@ -29,7 +34,7 @@ - name: Work out which stack manages the Wi-Fi ansible.builtin.set_fact: github_runner_linux_host_networkmanager_wifi_devices: "{{ github_runner_linux_host_devices.stdout_lines | map('split', ':') | selectattr('1', 'equalto', 'wifi') | rejectattr('2', 'equalto', 'unmanaged') | map('first') | list }}" - github_runner_linux_host_kernel_wifi_devices: "{{ github_runner_linux_host_kernel_devices.stdout_lines | map('dirname') | map('basename') | list }}" + github_runner_linux_host_kernel_wifi_devices: "{{ github_runner_linux_host_kernel_devices.stdout_lines }}" github_runner_linux_host_networkd_running: "{{ github_runner_linux_host_networkd.stdout | trim == 'active' }}" github_runner_linux_host_networkmanager_answered: "{{ github_runner_linux_host_devices.rc | default(1) == 0 }}" diff --git a/tests/unit/test_linux_host.py b/tests/unit/test_linux_host.py index 5d9f6e39..61655e72 100644 --- a/tests/unit/test_linux_host.py +++ b/tests/unit/test_linux_host.py @@ -130,6 +130,7 @@ def setUp(self) -> None: self.power_supply = self.host / "sys" / "class" / "power_supply" self.battery_unit = self.host / "etc" / "systemd" / "system" / "github-runner-battery-charge-limit.service" self.sysfs_net = self.host / "sys" / "class" / "net" + self.sysfs_devices = self.host / "sys" / "devices" self.wifi_unit = self.host / "etc" / "systemd" / "system" / "github-runner-wifi-powersave@.service" self.wifi_rule = self.host / "etc" / "udev" / "rules.d" / "90-github-runner-wifi-powersave.rules" self.apt_file = self.host / "etc" / "apt" / "apt.conf.d" / "52github-runner-unattended-upgrades" @@ -143,10 +144,26 @@ def setUp(self) -> None: self.add_kernel_wifi_device("wlp2s0") def add_kernel_wifi_device(self, name: str) -> None: - """Gives the stand-in sysfs a wireless network device, as cfg80211 does with its phy80211 link, and a wired one beside it that has none.""" - (self.sysfs_net / name).mkdir(parents=True, exist_ok=True) - (self.sysfs_net / name / "phy80211").touch() - (self.sysfs_net / "enp3s0").mkdir(exist_ok=True) + """Lays out network devices as a real sysfs does, so the role's probe runs against the same symlink structure with the real shell: /sys/class/net/ is a symlink to a directory under devices, which holds a phy80211 link when cfg80211 manages the device and a subsystem link back to the class directory (a loop, to anything that follows links); a wired device beside it has the subsystem link and no phy80211.""" + self.add_kernel_net_device(name, wireless=True) + self.add_kernel_net_device("enp3s0", wireless=False) + + def add_kernel_net_device(self, name: str, *, wireless: bool) -> None: + device = self.sysfs_devices / name / "net" / name + device.mkdir(parents=True, exist_ok=True) + (device / "subsystem").unlink(missing_ok=True) + (device / "subsystem").symlink_to(self.sysfs_net) + if wireless: + phy = self.sysfs_devices / name / "ieee80211" / "phy0" + phy.mkdir(parents=True, exist_ok=True) + (device / "phy80211").symlink_to(phy) + link = self.sysfs_net / name + if not link.is_symlink(): + link.symlink_to(device) + + def remove_kernel_net_device(self, name: str) -> None: + (self.sysfs_net / name).unlink() + shutil.rmtree(self.sysfs_devices / name) def make_stand_ins(self) -> None: log = 'echo "$(basename "$0") $*" >> "$STUB/calls.log"\n' @@ -360,7 +377,7 @@ def test_a_second_wifi_run_changes_nothing(self) -> None: def test_a_host_without_a_wifi_device_is_left_alone(self) -> None: (self.state / "devices").write_text("enp3s0:ethernet:connected\nlo:loopback:unmanaged\n") - shutil.rmtree(self.sysfs_net / "wlp2s0") + self.remove_kernel_net_device("wlp2s0") result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertFalse(self.networkmanager.exists()) @@ -380,7 +397,7 @@ def test_a_host_where_neither_stack_manages_a_wifi_device_is_refused_since_the_s def test_a_host_with_no_stack_at_all_is_refused_even_without_a_wifi_device(self) -> None: (self.state / "no-networkmanager").touch() - shutil.rmtree(self.sysfs_net / "wlp2s0") + self.remove_kernel_net_device("wlp2s0") result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) self.assertNotEqual(result.returncode, 0) self.assertIn("neither NetworkManager nor systemd-networkd manages a Wi-Fi device", re.sub(r"\s+", " ", result.stdout)) @@ -405,7 +422,7 @@ def networkd_host(self, device: str = "wifi0") -> None: """Makes the host one without NetworkManager that runs systemd-networkd, with its one Wi-Fi interface named as a .link file would.""" (self.state / "no-networkmanager").touch() (self.state / "active-systemd-networkd.service").touch() - shutil.rmtree(self.sysfs_net / "wlp2s0") + self.remove_kernel_net_device("wlp2s0") self.add_kernel_wifi_device(device) (self.state / f"power-save-{device}").touch() @@ -476,7 +493,7 @@ def test_a_networkd_check_run_with_iw_installed_reports_the_radio(self) -> None: def test_a_networkd_host_without_a_wifi_device_is_left_alone(self) -> None: self.networkd_host() - shutil.rmtree(self.sysfs_net / "wifi0") + self.remove_kernel_net_device("wifi0") result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(self.changes(), [])