From 34062447fcf6a8dd8710750d43965f3900629b28 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 2 Nov 2026 23:22:54 +0000 Subject: [PATCH] feat(linux-host): keep Wi-Fi from power saving under systemd-networkd The Wi-Fi power save step only knew NetworkManager and failed on a host without it. The stack is now read from the host: NetworkManager when it lists a Wi-Fi device it manages, else systemd-networkd when it is running and the kernel has a Wi-Fi network device. Neither systemd.network, systemd.link nor netplan has a power save setting, so a udev rule has systemd start a oneshot unit that runs `iw dev set power_save off` when each Wi-Fi interface appears, ordered after the interface's device unit. iw is installed with apt and the radio is also switched off now. A host where neither stack manages a Wi-Fi device still fails. --- roles/github_runner_linux_host/README.md | 23 ++- .../defaults/main.yml | 6 +- .../meta/argument_specs.yml | 14 +- .../tasks/wifi_powersave.yml | 129 ++++++-------- .../tasks/wifi_powersave_networkd.yml | 85 ++++++++++ .../tasks/wifi_powersave_networkmanager.yml | 41 +++++ .../tasks/wifi_powersave_radio.yml | 31 ++++ .../templates/wifi-powersave.rules.j2 | 4 + .../templates/wifi-powersave.service.j2 | 16 ++ tests/unit/test_linux_host.py | 159 +++++++++++++++++- 10 files changed, 417 insertions(+), 91 deletions(-) create mode 100644 roles/github_runner_linux_host/tasks/wifi_powersave_networkd.yml create mode 100644 roles/github_runner_linux_host/tasks/wifi_powersave_networkmanager.yml create mode 100644 roles/github_runner_linux_host/tasks/wifi_powersave_radio.yml create mode 100644 roles/github_runner_linux_host/templates/wifi-powersave.rules.j2 create mode 100644 roles/github_runner_linux_host/templates/wifi-powersave.service.j2 diff --git a/roles/github_runner_linux_host/README.md b/roles/github_runner_linux_host/README.md index 3167ed06..48ff81a4 100644 --- a/roles/github_runner_linux_host/README.md +++ b/roles/github_runner_linux_host/README.md @@ -10,9 +10,22 @@ Its tasks that change the host use `become` on their own, so the connecting user ## Wi-Fi power save -`github_runner_linux_host_wifi_powersave_off: true` tells NetworkManager not to power-save Wi-Fi. A radio in power-save mode sleeps between beacons and delays or drops packets, which on a node of the cluster shows as the node flapping `NotReady`. It matters only for a host on Wi-Fi, and does nothing to a host without a Wi-Fi device; on a host without NetworkManager it fails, since the setting is explicitly asked for. +`github_runner_linux_host_wifi_powersave_off: true` keeps the host's Wi-Fi from power saving. A radio in power-save mode sleeps between beacons and delays or drops packets, which on a node of the cluster shows as the node flapping `NotReady`. It matters only for a host on Wi-Fi, and does nothing to a host without a Wi-Fi device. The mechanism depends on what manages the Wi-Fi, which the role reads from the host: NetworkManager when `nmcli` lists a Wi-Fi device that is not `unmanaged`, else systemd-networkd when it is running and the kernel has a Wi-Fi network device (one with a `phy80211` link under `/sys/class/net`). A host where neither manages a Wi-Fi device fails, since the setting is explicitly asked for; a host with no Wi-Fi device at all is left alone as long as one of the two answers. -It writes `/etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf` with `wifi.powersave = 2` (disable), which sets the default for every connection that does not set its own, and has NetworkManager read it (`nmcli general reload conf`). NetworkManager applies a connection's power save setting each time it activates the connection, so that is what keeps it off across reconnects and reboots. The drop-in's name sorts after Ubuntu's `default-wifi-powersave-on.conf` (`wifi.powersave = 3`), because NetworkManager reads its configuration files in alphabetical order and the last one to set a value wins. To switch it off on a radio that is running now, the role runs `iw dev set power_save off` for each Wi-Fi device that reports it on, which changes the radio without taking the link down; `nmcli device reapply` does not change a running radio. A host without `iw` is told so in the run, and gets the setting at the next activation of its connection. +### NetworkManager + +The role writes `/etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf` with `wifi.powersave = 2` (disable), which sets the default for every connection that does not set its own, and has NetworkManager read it (`nmcli general reload conf`). NetworkManager applies a connection's power save setting each time it activates the connection, so that is what keeps it off across reconnects and reboots. The drop-in's name sorts after Ubuntu's `default-wifi-powersave-on.conf` (`wifi.powersave = 3`), because NetworkManager reads its configuration files in alphabetical order and the last one to set a value wins. To switch it off on a radio that is running now, the role runs `iw dev set power_save off` for each Wi-Fi device that reports it on, which changes the radio without taking the link down; `nmcli device reapply` does not change a running radio. A host without `iw` is told so in the run, and gets the setting at the next activation of its connection. + +### systemd-networkd + +Nothing configures Wi-Fi power save here: neither `systemd.network` nor `systemd.link` (checked in the v259 manual pages, where the only wireless settings are matching ones such as `WLANInterfaceType=` and `Type=wlan`) nor netplan has such a setting, and the kernel's per-interface preference is set with `iw`. So the role installs `iw` with apt (a host that is not Debian or Ubuntu fails) and writes two files: + +- `/etc/udev/rules.d/90-github-runner-wifi-powersave.rules`, which gives each Wi-Fi network device (`DEVTYPE=wlan`, set by cfg80211) a `SYSTEMD_WANTS` for the unit below when it is added, named by its final name (`$name`, after a `.link` file's rename), which is what systemd names the interface's device unit by. The role runs `udevadm control --reload` when the rule changes. +- `/etc/systemd/system/github-runner-wifi-powersave@.service`, a oneshot unit with `RemainAfterExit` that runs `iw dev set power_save off`. It is bound to and ordered after `sys-subsystem-net-devices-.device` (the same pattern as `wpa_supplicant@.service`), so it runs once udev has finished with the interface, has a status and a journal, and runs again if the interface goes away and comes back. The role runs `systemctl daemon-reload` when the unit changes. + +The rule matches every Wi-Fi interface, so one that appears later (a USB adapter) is covered without running the role again. The setting survives a reconnect, because the kernel keeps it per interface; brcmfmac, for one, stores the preference and applies it each time it brings the firmware up. The unit has not run for an interface that is already up, so the role also runs `iw dev set power_save off` on each one that reports it on. Unlike the NetworkManager path, an `iw` that cannot read an interface fails the run, since there is no later activation to rely on. Not every driver supports the setting; one that does not makes the unit fail, and shows in `systemctl status github-runner-wifi-powersave@`. + +Under `--check` both paths report each change and make none. A second run changes nothing. A Wi-Fi-only node still drops when the radio does. These settings only stop the radio adding drops of its own; see [A Wi-Fi-only host](../github_runner_cluster/README.md#a-wi-fi-only-host). @@ -60,7 +73,7 @@ A host that is not Debian or Ubuntu, a host without the package, and a package n ## Variables - `github_runner_linux_host_keep_awake` (default `false`): mask the sleep targets and have logind ignore the lid switch. -- `github_runner_linux_host_wifi_powersave_off` (default `false`): turn Wi-Fi power save off in NetworkManager and on the radio. +- `github_runner_linux_host_wifi_powersave_off` (default `false`): turn Wi-Fi power save off, in NetworkManager or, on a host without it that runs systemd-networkd, with a udev rule and a unit that run `iw`, and on the radio. - `github_runner_linux_host_k3s_prerequisites` (default `false`): load the modules and set the sysctls k3s needs, now and at boot. - `github_runner_linux_host_battery_charge_limit` (default `false`), `github_runner_linux_host_battery_charge_limit_percent` (default 80) and `github_runner_linux_host_battery_device` (default `BAT0`): limit the battery's charge, to what, and which battery. - `github_runner_linux_host_headless` (default `false`): boot to `multi-user.target` and stop and disable the display manager. @@ -70,9 +83,9 @@ A host that is not Debian or Ubuntu, a host without the package, and a package n - `github_runner_linux_host_upgrade` (default `false`): upgrade the packages, reboot if due, and autoremove, for one run. - `github_runner_linux_host_upgrade_reboot` (default `true`), `github_runner_linux_host_upgrade_reboot_running_node` (default `false`), `github_runner_linux_host_node_services` and `github_runner_linux_host_reboot_timeout` (default 600): whether the upgrade reboots, whether it may reboot a running node, which units make a host a running node, and how long it waits for the host to come back. - `github_runner_linux_host_sleep_targets`, `github_runner_linux_host_lid_switch_settings`, `github_runner_linux_host_kernel_modules`, `github_runner_linux_host_sysctl_settings` and `github_runner_linux_host_default_target`: the targets masked, the logind settings set to `ignore`, the modules loaded, the sysctls set and the target booted to. Constants, not settings; variables only so the tests can narrow them. -- `github_runner_linux_host_logind_dropin`, `github_runner_linux_host_networkmanager_dropin`, `github_runner_linux_host_modules_load_file`, `github_runner_linux_host_sysctl_file`, `github_runner_linux_host_sys_module_dir`, `github_runner_linux_host_power_supply_dir`, `github_runner_linux_host_battery_unit`, `github_runner_linux_host_unattended_upgrades_file`, `github_runner_linux_host_reboot_required_file`: where the role's files go and what it reads and runs. Not settings; variables only so the tests can point them at a stand-in. +- `github_runner_linux_host_logind_dropin`, `github_runner_linux_host_networkmanager_dropin`, `github_runner_linux_host_sysfs_net_dir`, `github_runner_linux_host_wifi_udev_rule`, `github_runner_linux_host_wifi_powersave_unit`, `github_runner_linux_host_modules_load_file`, `github_runner_linux_host_sysctl_file`, `github_runner_linux_host_sys_module_dir`, `github_runner_linux_host_power_supply_dir`, `github_runner_linux_host_battery_unit`, `github_runner_linux_host_unattended_upgrades_file`, `github_runner_linux_host_reboot_required_file`: where the role's files go and what it reads and runs. Not settings; variables only so the tests can point them at a stand-in. - `github_runner_linux_host_tailscale_update` (default `true`): keep the installed Tailscale package current and its service running, as under Keeping Tailscale current above. ## Tests -`tests/unit/test_linux_host.py` runs the role (`tests/linux_host/run.yml`) against stand-ins for `systemctl`, `nmcli`, `iw`, `modprobe`, `sysctl`, `apt-get`, `dpkg-query`, `uname` and `shutdown` on `PATH`, with every file the role writes or reads redirected and the host's facts stood in for. It checks for each step but the Tailscale update that it is off by default (and that the update is on, and leaves the host alone without the package), does what it says, changes nothing on a second run, and under `--check` reports each change and makes none; and in particular the order of the modules and the sysctls, a battery without the attribute, a limit outside 1 to 100, a display manager worked out from the alias, from its own unit when the alias is not loaded and from a unit the host names, a host with none, services the host does not have, the apt steps refused on a host without apt, the order of the upgrade, a running node's reboot refused with the packages already upgraded, and the defaults, argument specs and this README agreeing. +`tests/unit/test_linux_host.py` runs the role (`tests/linux_host/run.yml`) against stand-ins for `systemctl`, `nmcli`, `iw`, `udevadm`, `modprobe`, `sysctl`, `apt-get`, `dpkg-query`, `uname` and `shutdown` on `PATH`, with every file the role writes or reads redirected and the host's facts stood in for. It checks for each step but the Tailscale update that it is off by default (and that the update is on, and leaves the host alone without the package), does what it says, changes nothing on a second run, and under `--check` reports each change and makes none; and in particular the order of the modules and the sysctls, a battery without the attribute, a limit outside 1 to 100, a display manager worked out from the alias, from its own unit when the alias is not loaded and from a unit the host names, a host with none, services the host does not have, the apt steps refused on a host without apt, the order of the upgrade, a running node's reboot refused with the packages already upgraded, and the defaults, argument specs and this README agreeing. diff --git a/roles/github_runner_linux_host/defaults/main.yml b/roles/github_runner_linux_host/defaults/main.yml index 6e2c5fad..b506ab42 100644 --- a/roles/github_runner_linux_host/defaults/main.yml +++ b/roles/github_runner_linux_host/defaults/main.yml @@ -17,10 +17,14 @@ github_runner_linux_host_lid_switch_settings: # Not settings: where the logind drop-in goes, which systemd-logind reads on top of /etc/systemd/logind.conf. Variables only so the tests can point it at a stand-in. github_runner_linux_host_logind_dropin: /etc/systemd/logind.conf.d/90-github-runner-keep-awake.conf -# Whether NetworkManager is told not to power-save Wi-Fi (wifi.powersave = 2, disable). A Wi-Fi radio in power-save mode sleeps between beacons and drops or delays packets, which on a node of the cluster shows as the node flapping NotReady; it matters only for a host that is on Wi-Fi, and does nothing to a host without a Wi-Fi device. It sets the default for every connection that does not set its own, and applies to the radio now. +# Whether the host's Wi-Fi is kept from power saving. A Wi-Fi radio in power-save mode sleeps between beacons and drops or delays packets, which on a node of the cluster shows as the node flapping NotReady; it matters only for a host that is on Wi-Fi, and does nothing to a host without a Wi-Fi device. On a host whose Wi-Fi NetworkManager manages, NetworkManager is told not to (wifi.powersave = 2, disable) for every connection that does not set its own; on a host without NetworkManager that runs systemd-networkd, a udev rule and a systemd unit run `iw dev set power_save off` for each Wi-Fi interface when it appears, and iw is installed. Both apply to the radio now. A host where neither stack manages a Wi-Fi device fails. github_runner_linux_host_wifi_powersave_off: false # Not settings: where the NetworkManager drop-in goes. The name sorts after Ubuntu's default-wifi-powersave-on.conf (wifi.powersave = 3), because NetworkManager reads its configuration files in alphabetical order and the last one to set a value wins. Variables only so the tests can point them at a stand-in. github_runner_linux_host_networkmanager_dropin: /etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf +# Not settings: where the sysfs network class is read for the host's Wi-Fi devices, and where the udev rule and the systemd unit template of the systemd-networkd path go. The unit's name must end in @.service, since the rule instantiates it for each interface. Variables only so the tests can point them at a stand-in. +github_runner_linux_host_sysfs_net_dir: /sys/class/net +github_runner_linux_host_wifi_udev_rule: /etc/udev/rules.d/90-github-runner-wifi-powersave.rules +github_runner_linux_host_wifi_powersave_unit: /etc/systemd/system/github-runner-wifi-powersave@.service # What k3s needs of the kernel (https://docs.k3s.io/installation/requirements and the sysctls kube-proxy and the CNI plugins rely on). true loads the kernel modules now and at boot (a modules-load.d file), and sets the sysctls now and at boot (a sysctl.d file). k3s loads the modules and sets some of the sysctls itself when it starts, but the bridge ones only exist once br_netfilter is loaded, and a host that sets them before k3s starts does not depend on that. github_runner_linux_host_k3s_prerequisites: false diff --git a/roles/github_runner_linux_host/meta/argument_specs.yml b/roles/github_runner_linux_host/meta/argument_specs.yml index 58c20fef..52e4c598 100644 --- a/roles/github_runner_linux_host/meta/argument_specs.yml +++ b/roles/github_runner_linux_host/meta/argument_specs.yml @@ -34,11 +34,23 @@ argument_specs: github_runner_linux_host_wifi_powersave_off: type: bool default: false - description: Whether NetworkManager is told not to power-save Wi-Fi (wifi.powersave = 2), as the default for every connection and on the radio now. + description: Whether the host's Wi-Fi is kept from power saving, through NetworkManager (wifi.powersave = 2, as the default for every connection) when it manages a Wi-Fi device, else through a udev rule and a systemd unit that run iw for each Wi-Fi interface when systemd-networkd is running, and on the radio now. Fails when neither manages a Wi-Fi device. github_runner_linux_host_networkmanager_dropin: type: path default: /etc/NetworkManager/conf.d/zz-github-runner-wifi-powersave.conf description: Where the NetworkManager drop-in goes. Not a setting, and a variable only so the tests can point it at a stand-in. + github_runner_linux_host_sysfs_net_dir: + type: path + default: /sys/class/net + description: Where the sysfs network class is read for the host's Wi-Fi devices. Not a setting, and a variable only so the tests can point it at a stand-in. + github_runner_linux_host_wifi_udev_rule: + type: path + default: /etc/udev/rules.d/90-github-runner-wifi-powersave.rules + description: Where the udev rule goes that starts the power save unit for each Wi-Fi interface under systemd-networkd. Not a setting, and a variable only so the tests can point it at a stand-in. + github_runner_linux_host_wifi_powersave_unit: + type: path + default: /etc/systemd/system/github-runner-wifi-powersave@.service + description: Where the power save unit template goes, whose name must end in @.service. Not a setting, and a variable only so the tests can point it at a stand-in. github_runner_linux_host_k3s_prerequisites: type: bool default: false diff --git a/roles/github_runner_linux_host/tasks/wifi_powersave.yml b/roles/github_runner_linux_host/tasks/wifi_powersave.yml index dfcedadb..036e360a 100644 --- a/roles/github_runner_linux_host/tasks/wifi_powersave.yml +++ b/roles/github_runner_linux_host/tasks/wifi_powersave.yml @@ -1,90 +1,67 @@ --- -# Tells NetworkManager not to power-save Wi-Fi, as a drop-in that sets the default for every connection that does not set its own (which NetworkManager applies each time it activates a connection), and switches power save off on the radio now. Does nothing on a host that has no Wi-Fi device, and fails on one without NetworkManager (the setting is explicitly asked for). Idempotent, and under --check the change is reported and not made. +# Keeps Wi-Fi from power saving, through whichever network stack manages the host's Wi-Fi device: NetworkManager (wifi_powersave_networkmanager.yml) when it manages one, else systemd-networkd (wifi_powersave_networkd.yml) when it is running and the kernel has a Wi-Fi device. Does nothing on a host that has no Wi-Fi device and a stack that answers, and fails on a host where neither stack manages a Wi-Fi device (the setting is explicitly asked for). Idempotent, and under --check the change is reported and not made. -- name: Read the host's Wi-Fi devices - # The devices NetworkManager manages whose type is wifi, one per line; a host without NetworkManager has no nmcli, which is reported below. +- name: Read the host's Wi-Fi devices from NetworkManager + # The devices NetworkManager lists with their type and state, one per line. A device it lists as unmanaged is not its to configure, and a host without NetworkManager has no nmcli, which is reported below. ansible.builtin.command: - argv: [nmcli, --terse, --fields, "DEVICE,TYPE", device, status] + argv: [nmcli, --terse, --fields, "DEVICE,TYPE,STATE", device, status] register: github_runner_linux_host_devices changed_when: false failed_when: false check_mode: false -- name: Fail if the host has no NetworkManager - ansible.builtin.fail: - msg: >- - github_runner_linux_host_wifi_powersave_off is true, but nmcli did not answer on {{ inventory_hostname }} - ({{ github_runner_linux_host_devices.msg | default(github_runner_linux_host_devices.stderr) | default('no output') | trim }}); - the setting is NetworkManager's. Nothing was changed. - when: github_runner_linux_host_devices.rc | default(1) != 0 - -- name: Work out the Wi-Fi devices - ansible.builtin.set_fact: - github_runner_linux_host_wifi_devices: "{{ github_runner_linux_host_devices.stdout_lines | select('match', '^[^:]+:wifi$') | map('split', ':') | map('first') | list }}" - -- name: Tell NetworkManager not to power-save Wi-Fi - when: github_runner_linux_host_wifi_devices | length > 0 - block: - - name: Create the directory of the NetworkManager drop-in - become: true - ansible.builtin.file: - path: "{{ github_runner_linux_host_networkmanager_dropin | dirname }}" - state: directory - mode: "0755" +- 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. + ansible.builtin.command: + argv: [find, -L, "{{ github_runner_linux_host_sysfs_net_dir }}", -mindepth, "2", -maxdepth, "2", -name, phy80211] + register: github_runner_linux_host_kernel_devices + changed_when: false + check_mode: false - - name: Write the drop-in - become: true - ansible.builtin.copy: - content: | - # Managed by the exadev.github_runner collection's github_runner_linux_host role. Do not edit by hand. - # 2 is NM_SETTING_WIRELESS_POWERSAVE_DISABLE. The name sorts after default-wifi-powersave-on.conf, which sets 3. - [connection] - wifi.powersave = 2 - dest: "{{ github_runner_linux_host_networkmanager_dropin }}" - mode: "0644" - register: github_runner_linux_host_networkmanager +- name: Read whether systemd-networkd is running + ansible.builtin.command: + argv: [systemctl, is-active, systemd-networkd.service] + register: github_runner_linux_host_networkd + changed_when: false + failed_when: false + check_mode: false - - name: Make NetworkManager read the drop-in - become: true - ansible.builtin.command: - argv: [nmcli, general, reload, conf] - changed_when: true - when: - - github_runner_linux_host_networkmanager is changed - - not ansible_check_mode +- 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_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 }}" - - name: Read whether each Wi-Fi device is power saving now - # `nmcli device reapply` and the reload above leave a running radio as it is: NetworkManager sets power save when it activates a connection. iw reads and sets it on the radio itself, with no change to the link, so an SSH session over it survives. A host without iw is reported below. - ansible.builtin.command: - argv: [iw, dev, "{{ item }}", get, power_save] - loop: "{{ github_runner_linux_host_wifi_devices }}" - register: github_runner_linux_host_power_save - changed_when: false - failed_when: false - check_mode: false +- name: Choose the stack + ansible.builtin.set_fact: + github_runner_linux_host_wifi_stack: >- + {{ 'networkmanager' if github_runner_linux_host_networkmanager_wifi_devices | length > 0 + else 'networkd' if github_runner_linux_host_kernel_wifi_devices | length > 0 and github_runner_linux_host_networkd_running + else 'none' }} - - name: Switch power save off on each Wi-Fi device that has it on - become: true - ansible.builtin.command: - argv: [iw, dev, "{{ item.item }}", set, power_save, "off"] - loop: "{{ github_runner_linux_host_power_save.results | selectattr('rc', 'equalto', 0) | selectattr('stdout', 'search', 'Power save: on') | list }}" - loop_control: - label: "{{ item.item }}" - changed_when: true - when: not ansible_check_mode +- name: Fail if no network stack manages a Wi-Fi device + ansible.builtin.fail: + msg: >- + github_runner_linux_host_wifi_powersave_off is true, but neither NetworkManager nor systemd-networkd manages a Wi-Fi device on {{ inventory_hostname }} + (nmcli: {{ 'answered, no managed Wi-Fi device' if github_runner_linux_host_networkmanager_answered else github_runner_linux_host_devices.stderr | default(github_runner_linux_host_devices.msg, true) | default('no output', true) | trim }}; + systemd-networkd: {{ github_runner_linux_host_networkd.stdout | trim | default('unknown', true) }}; + Wi-Fi devices the kernel has: {{ github_runner_linux_host_kernel_wifi_devices | join(', ') | default('none', true) }}). + Nothing was changed. + when: + - github_runner_linux_host_wifi_stack == 'none' + - >- + github_runner_linux_host_kernel_wifi_devices | length > 0 + or not (github_runner_linux_host_networkmanager_answered or github_runner_linux_host_networkd_running) - - name: Report the devices a real run would switch power save off on - ansible.builtin.debug: - msg: "iw dev {{ item.item }} set power_save off" - loop: "{{ github_runner_linux_host_power_save.results | selectattr('rc', 'equalto', 0) | selectattr('stdout', 'search', 'Power save: on') | list }}" - loop_control: - label: "{{ item.item }}" - changed_when: true - when: ansible_check_mode +- name: Tell NetworkManager not to power-save Wi-Fi + vars: + github_runner_linux_host_wifi_devices: "{{ github_runner_linux_host_networkmanager_wifi_devices }}" + ansible.builtin.include_tasks: wifi_powersave_networkmanager.yml + when: github_runner_linux_host_wifi_stack == 'networkmanager' - - name: Say that the radio could not be read, so the setting applies when NetworkManager next activates the connection - ansible.builtin.debug: - msg: "iw cannot read {{ item.item }}'s power save ({{ item.msg | default(item.stderr) | default('no output') | trim }}); the drop-in applies when NetworkManager next activates its connection, or after a reboot." - loop: "{{ github_runner_linux_host_power_save.results | rejectattr('rc', 'equalto', 0) | list }}" - loop_control: - label: "{{ item.item }}" +- name: Keep Wi-Fi from power saving under systemd-networkd + vars: + github_runner_linux_host_wifi_devices: "{{ github_runner_linux_host_kernel_wifi_devices }}" + ansible.builtin.include_tasks: wifi_powersave_networkd.yml + when: github_runner_linux_host_wifi_stack == 'networkd' diff --git a/roles/github_runner_linux_host/tasks/wifi_powersave_networkd.yml b/roles/github_runner_linux_host/tasks/wifi_powersave_networkd.yml new file mode 100644 index 00000000..1fd3be46 --- /dev/null +++ b/roles/github_runner_linux_host/tasks/wifi_powersave_networkd.yml @@ -0,0 +1,85 @@ +--- +# Keeps Wi-Fi from power saving on a host whose network stack is systemd-networkd (Ubuntu Server's netplan renderer), where nothing configures it: neither systemd.network, systemd.link nor netplan has a power save setting, and the kernel's per-interface preference is set with iw. So a udev rule has systemd start a oneshot unit, github-runner-wifi-powersave@.service, each time a Wi-Fi interface appears, which runs `iw dev set power_save off`; the unit is ordered after udev has finished with the interface, so it runs on the interface's name after any rename, and being a unit it has a status and a journal. The rule matches every Wi-Fi interface, so a device that appears later is covered without the role running again. The preference stays set across a reconnect (the driver keeps it, which brcmfmac stores in its own state and applies each time it brings the firmware up) and is lost only when the interface goes away, when the unit runs again as the interface comes back. The radio is also switched off now, since the unit has not run for an interface that is already up. Included with github_runner_linux_host_wifi_devices set to the host's Wi-Fi interfaces. Installs iw, with apt. Idempotent, and under --check each change is reported and none is made. + +- name: Fail unless the host is a Debian or Ubuntu host + ansible.builtin.fail: + msg: >- + github_runner_linux_host_wifi_powersave_off needs iw on a host without NetworkManager, installed with apt, and {{ inventory_hostname }} is a {{ ansible_facts['os_family'] }} host. + Nothing was changed. + when: ansible_facts['os_family'] != 'Debian' + +- name: Read whether iw is installed + ansible.builtin.command: + argv: [dpkg-query, "--showformat=${db:Status-Abbrev}", --show, iw] + register: github_runner_linux_host_iw_package + changed_when: false + failed_when: false + check_mode: false + +- name: Install iw + become: true + ansible.builtin.command: + argv: [apt-get, install, --yes, --no-install-recommends, iw] + environment: + DEBIAN_FRONTEND: noninteractive + changed_when: true + when: + - github_runner_linux_host_iw_package.stdout | trim != 'ii' + - not ansible_check_mode + +- name: Report the package install a real run would make + ansible.builtin.debug: + msg: "apt-get install iw" + changed_when: true + when: + - github_runner_linux_host_iw_package.stdout | trim != 'ii' + - ansible_check_mode + +- name: Write the unit that switches power save off on an interface + become: true + ansible.builtin.template: + src: wifi-powersave.service.j2 + dest: "{{ github_runner_linux_host_wifi_powersave_unit }}" + mode: "0644" + register: github_runner_linux_host_wifi_unit + +- name: Make systemd read the unit + become: true + ansible.builtin.command: + argv: [systemctl, daemon-reload] + changed_when: true + when: + - github_runner_linux_host_wifi_unit is changed + - not ansible_check_mode + +- name: Write the udev rule that starts the unit for each Wi-Fi interface + become: true + ansible.builtin.template: + src: wifi-powersave.rules.j2 + dest: "{{ github_runner_linux_host_wifi_udev_rule }}" + mode: "0644" + register: github_runner_linux_host_wifi_rule + +- name: Make udev read the rule + become: true + ansible.builtin.command: + argv: [udevadm, control, --reload] + changed_when: true + when: + - github_runner_linux_host_wifi_rule is changed + - not ansible_check_mode + +- name: Switch power save off on the radio now + # Under --check with iw not yet installed there is nothing to read the radio with: the install above is what a real run would make first. + ansible.builtin.include_tasks: wifi_powersave_radio.yml + when: github_runner_linux_host_iw_package.stdout | trim == 'ii' or not ansible_check_mode + +- name: Fail if iw cannot read a Wi-Fi device + ansible.builtin.fail: + msg: >- + iw cannot read {{ item.item }}'s power save on {{ inventory_hostname }} + ({{ item.msg | default(item.stderr) | default('no output') | trim }}). The unit and the rule are in place, but the radio was not changed. + loop: "{{ github_runner_linux_host_power_save.results | rejectattr('rc', 'equalto', 0) | list }}" + loop_control: + label: "{{ item.item }}" + when: github_runner_linux_host_iw_package.stdout | trim == 'ii' or not ansible_check_mode diff --git a/roles/github_runner_linux_host/tasks/wifi_powersave_networkmanager.yml b/roles/github_runner_linux_host/tasks/wifi_powersave_networkmanager.yml new file mode 100644 index 00000000..cd005983 --- /dev/null +++ b/roles/github_runner_linux_host/tasks/wifi_powersave_networkmanager.yml @@ -0,0 +1,41 @@ +--- +# Tells NetworkManager not to power-save Wi-Fi, as a drop-in that sets the default for every connection that does not set its own (which NetworkManager applies each time it activates a connection), and switches power save off on the radio now. Included with github_runner_linux_host_wifi_devices set to the Wi-Fi devices NetworkManager manages. Idempotent, and under --check the change is reported and not made. + +- name: Create the directory of the NetworkManager drop-in + become: true + ansible.builtin.file: + path: "{{ github_runner_linux_host_networkmanager_dropin | dirname }}" + state: directory + mode: "0755" + +- name: Write the drop-in + become: true + ansible.builtin.copy: + content: | + # Managed by the exadev.github_runner collection's github_runner_linux_host role. Do not edit by hand. + # 2 is NM_SETTING_WIRELESS_POWERSAVE_DISABLE. The name sorts after default-wifi-powersave-on.conf, which sets 3. + [connection] + wifi.powersave = 2 + dest: "{{ github_runner_linux_host_networkmanager_dropin }}" + mode: "0644" + register: github_runner_linux_host_networkmanager + +- name: Make NetworkManager read the drop-in + become: true + ansible.builtin.command: + argv: [nmcli, general, reload, conf] + changed_when: true + when: + - github_runner_linux_host_networkmanager is changed + - not ansible_check_mode + +- name: Switch power save off on the radio now + # `nmcli device reapply` and the reload above leave a running radio as it is: NetworkManager sets power save when it activates a connection. A host without iw is reported below. + ansible.builtin.include_tasks: wifi_powersave_radio.yml + +- name: Say that the radio could not be read, so the setting applies when NetworkManager next activates the connection + ansible.builtin.debug: + msg: "iw cannot read {{ item.item }}'s power save ({{ item.msg | default(item.stderr) | default('no output') | trim }}); the drop-in applies when NetworkManager next activates its connection, or after a reboot." + loop: "{{ github_runner_linux_host_power_save.results | rejectattr('rc', 'equalto', 0) | list }}" + loop_control: + label: "{{ item.item }}" diff --git a/roles/github_runner_linux_host/tasks/wifi_powersave_radio.yml b/roles/github_runner_linux_host/tasks/wifi_powersave_radio.yml new file mode 100644 index 00000000..72043954 --- /dev/null +++ b/roles/github_runner_linux_host/tasks/wifi_powersave_radio.yml @@ -0,0 +1,31 @@ +--- +# Switches power save off on the radio of each device in github_runner_linux_host_wifi_devices that has it on, so the setting holds now and not only from the next time the host's network stack brings the device up. Needs iw. Registers github_runner_linux_host_power_save for the includer, which decides what a device iw cannot read means. Idempotent, and under --check the change is reported and not made. + +- name: Read whether each Wi-Fi device is power saving now + # iw reads and sets it on the radio itself, with no change to the link, so an SSH session over it survives. + ansible.builtin.command: + argv: [iw, dev, "{{ item }}", get, power_save] + loop: "{{ github_runner_linux_host_wifi_devices }}" + register: github_runner_linux_host_power_save + changed_when: false + failed_when: false + check_mode: false + +- name: Switch power save off on each Wi-Fi device that has it on + become: true + ansible.builtin.command: + argv: [iw, dev, "{{ item.item }}", set, power_save, "off"] + loop: "{{ github_runner_linux_host_power_save.results | selectattr('rc', 'equalto', 0) | selectattr('stdout', 'search', 'Power save: on') | list }}" + loop_control: + label: "{{ item.item }}" + changed_when: true + when: not ansible_check_mode + +- name: Report the devices a real run would switch power save off on + ansible.builtin.debug: + msg: "iw dev {{ item.item }} set power_save off" + loop: "{{ github_runner_linux_host_power_save.results | selectattr('rc', 'equalto', 0) | selectattr('stdout', 'search', 'Power save: on') | list }}" + loop_control: + label: "{{ item.item }}" + changed_when: true + when: ansible_check_mode diff --git a/roles/github_runner_linux_host/templates/wifi-powersave.rules.j2 b/roles/github_runner_linux_host/templates/wifi-powersave.rules.j2 new file mode 100644 index 00000000..ea9f9ab3 --- /dev/null +++ b/roles/github_runner_linux_host/templates/wifi-powersave.rules.j2 @@ -0,0 +1,4 @@ +# Managed by the exadev.github_runner collection's github_runner_linux_host role. Do not edit by hand: change this host's variables and run the role again. +# +# Has systemd start the power save unit for each Wi-Fi interface (cfg80211 gives its network devices DEVTYPE wlan) when the interface appears. $name is the interface's name after any rename, which is what systemd names its device unit by (99-systemd.rules sets SYSTEMD_ALIAS from it). The tag is what makes systemd read SYSTEMD_WANTS at all, and is also set by 99-systemd.rules. +ACTION=="add", SUBSYSTEM=="net", ENV{DEVTYPE}=="wlan", TAG+="systemd", ENV{SYSTEMD_WANTS}+="{{ github_runner_linux_host_wifi_powersave_unit | basename | replace('@.service', '@$name.service') }}" diff --git a/roles/github_runner_linux_host/templates/wifi-powersave.service.j2 b/roles/github_runner_linux_host/templates/wifi-powersave.service.j2 new file mode 100644 index 00000000..ee2c470a --- /dev/null +++ b/roles/github_runner_linux_host/templates/wifi-powersave.service.j2 @@ -0,0 +1,16 @@ +# Managed by the exadev.github_runner collection's github_runner_linux_host role. Do not edit by hand: change this host's variables and run the role again. + +[Unit] + +Description=Switch Wi-Fi power save off on %i +# The instance is the interface's name, as the udev rule names it. The device unit for it exists once udev has finished with the interface, and the service goes when the interface does, so it runs again when the interface comes back. +BindsTo=sys-subsystem-net-devices-%i.device +After=sys-subsystem-net-devices-%i.device + + +[Service] + +Type=oneshot +RemainAfterExit=yes +# A file name without a slash is looked for in systemd's own PATH (systemd.service, "Command lines"). +ExecStart=iw dev %i set power_save off diff --git a/tests/unit/test_linux_host.py b/tests/unit/test_linux_host.py index fbf1fc65..5d9f6e39 100644 --- a/tests/unit/test_linux_host.py +++ b/tests/unit/test_linux_host.py @@ -1,4 +1,4 @@ -"""Tests the github_runner_linux_host role: its defaults, argument specs and README, and its tasks (tests/linux_host/run.yml) against stand-ins for systemctl, nmcli, iw, modprobe, sysctl, apt-get, dpkg-query, uname and shutdown on PATH, with every file the role writes or reads redirected, so that no real target, drop-in, radio, module, package or reboot is touched.""" +"""Tests the github_runner_linux_host role: its defaults, argument specs and README, and its tasks (tests/linux_host/run.yml) against stand-ins for systemctl, nmcli, iw, udevadm, modprobe, sysctl, apt-get, dpkg-query, uname and shutdown on PATH, with every file the role writes or reads redirected, so that no real target, drop-in, radio, module, package or reboot is touched.""" from __future__ import annotations @@ -129,14 +129,24 @@ def setUp(self) -> None: self.sys_module = self.host / "sys" / "module" 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.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" self.reboot_required = self.host / "var" / "run" / "reboot-required" # Directories every host has already: the role puts files in them and creates no others of the system's. - for path in (self.sys_module, self.power_supply, self.reboot_required.parent, self.battery_unit.parent, self.modules_file.parent, self.sysctl_file.parent, self.apt_file.parent): + for path in (self.sys_module, self.power_supply, self.sysfs_net, self.reboot_required.parent, self.battery_unit.parent, self.wifi_rule.parent, self.modules_file.parent, self.sysctl_file.parent, self.apt_file.parent): path.mkdir(parents=True, exist_ok=True) self.make_stand_ins() - (self.state / "devices").write_text("wlp2s0:wifi\ntailscale0:tun\nlo:loopback\np2p-dev-wlp2s0:wifi-p2p\n") + (self.state / "devices").write_text("wlp2s0:wifi:connected\ntailscale0:tun:unmanaged\nlo:loopback:unmanaged\np2p-dev-wlp2s0:wifi-p2p:disconnected\n") (self.state / "power-save-wlp2s0").touch() + 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) def make_stand_ins(self) -> None: log = 'echo "$(basename "$0") $*" >> "$STUB/calls.log"\n' @@ -179,6 +189,7 @@ def make_stand_ins(self) -> None: set) rm -f "$STUB/state/power-save-$device" ;; esac """) + stand_in(bin_dir / "udevadm", log) stand_in(bin_dir / "modprobe", log + r"""touch "$STUB/sys-module-$2" mkdir -p "$SYS_MODULE" && touch "$SYS_MODULE/$2" """) @@ -196,6 +207,7 @@ def make_stand_ins(self) -> None: simulate=no; for argument in "$@"; do [ "$argument" = --simulate ] && simulate=yes; done case " $* " in *" install "*unattended-upgrades*) [ "$simulate" = yes ] || touch "$state/package-unattended-upgrades" ;; + *" install "*" iw "*) [ "$simulate" = yes ] || touch "$state/package-iw" ;; *" install "*"--only-upgrade tailscale "*) if [ "$simulate" = no ] && [ -e "$state/tailscale-candidate" ]; then cp "$state/tailscale-candidate" "$state/tailscale-installed"; fi ;; *" dist-upgrade"*) @@ -226,6 +238,7 @@ def make_stand_ins(self) -> None: stand_in(bin_dir / "dpkg-query", r"""case "$*" in *tailscale*) if [ -e "$STUB/state/tailscale-installed" ]; then printf 'ii '; else exit 1; fi ;; *unattended-upgrades*) if [ -e "$STUB/state/package-unattended-upgrades" ]; then printf 'ii '; else exit 1; fi ;; + *iw*) if [ -e "$STUB/state/package-iw" ]; then printf 'ii '; else exit 1; fi ;; esac """) # Stands in for the sudo that become runs, so that a test can see which tasks asked for privilege: it logs, then runs the command it was given as the connecting user. @@ -243,6 +256,9 @@ def run_role(self, *args: str, check: bool = False, system: str = "Linux", os_fa "ansible_become": False, "github_runner_linux_host_logind_dropin": str(self.logind), "github_runner_linux_host_networkmanager_dropin": str(self.networkmanager), + "github_runner_linux_host_sysfs_net_dir": str(self.sysfs_net), + "github_runner_linux_host_wifi_udev_rule": str(self.wifi_rule), + "github_runner_linux_host_wifi_powersave_unit": str(self.wifi_unit), "github_runner_linux_host_modules_load_file": str(self.modules_file), "github_runner_linux_host_sysctl_file": str(self.sysctl_file), "github_runner_linux_host_sys_module_dir": str(self.sys_module), @@ -279,7 +295,7 @@ def calls(self) -> list[str]: return log.read_text().splitlines() if log.exists() else [] def changes(self) -> list[str]: - return [call for call in self.calls() if re.match(r"^(systemctl (mask|kill|enable|disable|stop|set-default)|nmcli general|iw dev \S+ set|modprobe|sysctl -w|apt-get|shutdown)", call)] + return [call for call in self.calls() if re.match(r"^(systemctl (mask|kill|enable|disable|stop|set-default|daemon-reload)|nmcli general|iw dev \S+ set|udevadm|modprobe|sysctl -w|apt-get|shutdown)", call)] def changed(self, result: subprocess.CompletedProcess[str]) -> int: return int(re.search(r"changed=(\d+)", result.stdout).group(1)) # type: ignore[union-attr] @@ -343,18 +359,40 @@ def test_a_second_wifi_run_changes_nothing(self) -> None: self.assertEqual([call for call in self.calls()[before:] if re.match(r"^(nmcli general|iw dev \S+ set)", call)], []) def test_a_host_without_a_wifi_device_is_left_alone(self) -> None: - (self.state / "devices").write_text("enp3s0:ethernet\nlo:loopback\n") + (self.state / "devices").write_text("enp3s0:ethernet:connected\nlo:loopback:unmanaged\n") + shutil.rmtree(self.sysfs_net / "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()) self.assertEqual(self.changes(), []) - def test_a_host_without_networkmanager_is_refused_since_the_setting_was_asked_for(self) -> None: + def test_a_host_where_neither_stack_manages_a_wifi_device_is_refused_since_the_setting_was_asked_for(self) -> None: (self.state / "no-networkmanager").touch() result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) self.assertNotEqual(result.returncode, 0) - self.assertIn("nmcli did not answer", re.sub(r"\s+", " ", result.stdout)) - self.assertFalse(self.networkmanager.exists()) + message = re.sub(r"\s+", " ", result.stdout) + self.assertIn("neither NetworkManager nor systemd-networkd manages a Wi-Fi device", message) + self.assertIn("nmcli: nmcli: not running", message) + self.assertIn("systemd-networkd: inactive", message) + self.assertIn("Wi-Fi devices the kernel has: wlp2s0", message) + self.assertFalse(self.networkmanager.exists() or self.wifi_unit.exists() or self.wifi_rule.exists()) + self.assertEqual(self.changes(), []) + + 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") + 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)) + self.assertEqual(self.changes(), []) + + def test_networkmanager_alone_decides_when_it_manages_a_wifi_device_even_if_networkd_is_running(self) -> None: + (self.state / "active-systemd-networkd.service").touch() + result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(self.networkmanager.is_file()) + self.assertFalse(self.wifi_unit.exists() or self.wifi_rule.exists()) + self.assertEqual(self.changes(), ["nmcli general reload conf", "iw dev wlp2s0 set power_save off"]) def test_a_host_without_iw_gets_the_drop_in_and_is_told_the_radio_was_not_changed(self) -> None: (self.state / "no-iw").touch() @@ -363,6 +401,111 @@ def test_a_host_without_iw_gets_the_drop_in_and_is_told_the_radio_was_not_change self.assertTrue(self.networkmanager.is_file()) self.assertIn("the drop-in applies when NetworkManager next activates its connection", re.sub(r"\s+", " ", result.stdout)) + 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.add_kernel_wifi_device(device) + (self.state / f"power-save-{device}").touch() + + def test_wifi_power_save_under_networkd_is_a_udev_rule_a_unit_iw_and_the_radio_now(self) -> None: + self.networkd_host() + 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(), ["apt-get install --yes --no-install-recommends iw", "systemctl daemon-reload", "udevadm control --reload", "iw dev wifi0 set power_save off"]) + self.assertFalse(self.networkmanager.exists()) + unit = self.wifi_unit.read_text() + self.assertIn("\nBindsTo=sys-subsystem-net-devices-%i.device\n", unit) + self.assertIn("\nAfter=sys-subsystem-net-devices-%i.device\n", unit) + self.assertIn("\nType=oneshot\n", unit) + self.assertIn("\nExecStart=iw dev %i set power_save off\n", unit) + rule = self.wifi_rule.read_text() + self.assertIn('ACTION=="add", SUBSYSTEM=="net", ENV{DEVTYPE}=="wlan", TAG+="systemd", ENV{SYSTEMD_WANTS}+="github-runner-wifi-powersave@$name.service"\n', rule) + self.assertEqual((stat.S_IMODE(self.wifi_unit.stat().st_mode), stat.S_IMODE(self.wifi_rule.stat().st_mode)), (0o644, 0o644)) + self.assertFalse((self.state / "power-save-wifi0").exists()) + + def test_a_second_networkd_run_changes_nothing(self) -> None: + self.networkd_host() + self.assertEqual(self.run_role(github_runner_linux_host_wifi_powersave_off=True).returncode, 0) + before = len(self.calls()) + result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.changed(result), 0, result.stdout) + self.assertEqual([call for call in self.calls()[before:] if re.match(r"^(apt-get|udevadm|systemctl daemon-reload|iw dev \S+ set)", call)], []) + + def test_networkd_installs_nothing_when_iw_is_already_there(self) -> None: + self.networkd_host() + (self.state / "package-iw").touch() + 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(), ["systemctl daemon-reload", "udevadm control --reload", "iw dev wifi0 set power_save off"]) + + def test_every_wifi_interface_the_kernel_has_is_switched_and_a_wired_one_is_not(self) -> None: + self.networkd_host() + self.add_kernel_wifi_device("wlan1") + (self.state / "power-save-wlan1").touch() + result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(sorted(call for call in self.changes() if call.startswith("iw ")), ["iw dev wifi0 set power_save off", "iw dev wlan1 set power_save off"]) + + def test_a_networkd_radio_already_off_is_left_and_the_unit_and_rule_are_still_written(self) -> None: + self.networkd_host() + (self.state / "power-save-wifi0").unlink() + result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual([call for call in self.changes() if call.startswith("iw ")], []) + self.assertTrue(self.wifi_unit.is_file() and self.wifi_rule.is_file()) + + def test_a_networkd_check_run_reports_each_change_and_makes_none(self) -> None: + self.networkd_host() + result = self.run_role(check=True, github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.changes(), []) + self.assertFalse(self.wifi_unit.exists() or self.wifi_rule.exists()) + self.assertIn("apt-get install iw", result.stdout) + self.assertEqual([call for call in self.calls() if call.startswith("iw ")], []) + + def test_a_networkd_check_run_with_iw_installed_reports_the_radio(self) -> None: + self.networkd_host() + (self.state / "package-iw").touch() + result = self.run_role(check=True, github_runner_linux_host_wifi_powersave_off=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.changes(), []) + self.assertIn("iw dev wifi0 set power_save off", result.stdout) + + def test_a_networkd_host_without_a_wifi_device_is_left_alone(self) -> None: + self.networkd_host() + shutil.rmtree(self.sysfs_net / "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(), []) + self.assertFalse(self.wifi_unit.exists() or self.wifi_rule.exists()) + + def test_a_wifi_device_networkmanager_lists_as_unmanaged_is_left_to_networkd(self) -> None: + (self.state / "devices").write_text("wlp2s0:wifi:unmanaged\nlo:loopback:unmanaged\n") + (self.state / "active-systemd-networkd.service").touch() + 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()) + self.assertTrue(self.wifi_unit.is_file() and self.wifi_rule.is_file()) + self.assertIn("iw dev wlp2s0 set power_save off", self.changes()) + + def test_networkd_refuses_a_host_without_apt_before_changing_anything(self) -> None: + self.networkd_host() + result = self.run_role(os_family="RedHat", github_runner_linux_host_wifi_powersave_off=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("needs iw on a host without NetworkManager, installed with apt", re.sub(r"\s+", " ", result.stdout)) + self.assertEqual(self.changes(), []) + self.assertFalse(self.wifi_unit.exists() or self.wifi_rule.exists()) + + def test_networkd_fails_when_iw_cannot_read_the_radio_rather_than_leaving_it_unchanged(self) -> None: + self.networkd_host() + (self.state / "no-iw").touch() + result = self.run_role(github_runner_linux_host_wifi_powersave_off=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("iw cannot read wifi0's power save", re.sub(r"\s+", " ", result.stdout)) + def test_a_host_that_is_not_linux_is_left_alone(self) -> None: result = self.run_role(system="Darwin", github_runner_linux_host_keep_awake=True, github_runner_linux_host_wifi_powersave_off=True) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)