diff --git a/CHANGELOG.md b/CHANGELOG.md index 341c1f3c..68790511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -**Highlights:** On RHEL 8, a MariaDB package upgrade no longer cuts applications on the same host off from their database. Apache no longer loads `mod_info`, which served the complete configuration including other modules' credentials. A broken PHP-FPM configuration aborts the run instead of taking the service down on the restart. Sudo rules deployed by `freeipa_server` can carry their commands again. The Bitwarden lookup can be told to abort instead of silently generating a new password, for runs against hosts whose credentials must already exist. The Grafana graph configuration for the Monitoring Plugins is no longer deployed on every ordinary run and has to be requested explicitly by its tag. +**Highlights:** Kernel parameters that only take effect at boot time, `psi=1` among them, can be deployed from the inventory with the new `bootloader` role. On RHEL 8, a MariaDB package upgrade no longer cuts applications on the same host off from their database. Apache no longer loads `mod_info`, which served the complete configuration including other modules' credentials. A broken PHP-FPM configuration aborts the run instead of taking the service down on the restart. Sudo rules deployed by `freeipa_server` can carry their commands again. The Bitwarden lookup can be told to abort instead of silently generating a new password, for runs against hosts whose credentials must already exist. The Grafana graph configuration for the Monitoring Plugins is no longer deployed on every ordinary run and has to be requested explicitly by its tag. ### Breaking Changes @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:bootloader**: New role that manages the kernel command line, for parameters that only take effect at boot time such as `psi=1`. Options are applied to every boot entry of the host, on the Red Hat family with `grubby` and on Debian and Ubuntu through a GRUB drop-in of its own. A changed command line requests a reboot at the maintenance window instead of rebooting right away, and a `--check` run reports what it would change without touching the host. * **role:files**: A file can opt out of the backup copy that is written before it is overwritten, via the `backup` subkey of `files__files__*_var`. * **role:collabora**: The `collabora:configure` tag deploys `coolwsd.xml` and the logrotate configuration without touching the packages. * **role:docker**: The address pools docker assigns container network subnets from (`default-address-pools`) can be configured. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 7db4f2a1..9de965a6 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -15,6 +15,7 @@ Which Ansible role is proven to run on which OS? | audit | | | x | x | (x) | | | | | | bind | | | x | x | x | | | | | | blocky | | | x | x | (x) | | | | | +| bootloader | (x) | x | x | x | x | (x) | (x) | (x) | Fedora 44 | | borg_local | | | x | (x) | (x) | | | | | | chromium_headless | x | (x) | x | x | x | | | | | | chrony | | | x | x | x | | | | | diff --git a/extensions/molecule/bootloader/converge.yml b/extensions/molecule/bootloader/converge.yml new file mode 100644 index 00000000..320eba09 --- /dev/null +++ b/extensions/molecule/bootloader/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge bootloader playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.bootloader' diff --git a/extensions/molecule/bootloader/inventory/group_vars/systems_under_test.yml b/extensions/molecule/bootloader/inventory/group_vars/systems_under_test.yml new file mode 100644 index 00000000..a6a3471c --- /dev/null +++ b/extensions/molecule/bootloader/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,3 @@ +bootloader__cmdline_options__group_var: + - name: 'psi' + value: 1 diff --git a/extensions/molecule/bootloader/inventory/hosts.yml b/extensions/molecule/bootloader/inventory/hosts.yml new file mode 100644 index 00000000..ea8d641b --- /dev/null +++ b/extensions/molecule/bootloader/inventory/hosts.yml @@ -0,0 +1,15 @@ +# yamllint disable rule:empty-values +lfops_bootloader: + children: + systems_under_test: + +systems_under_test: + hosts: + debian12-vm: + debian13-vm: + rocky8-vm: + rocky9-vm: + rocky10-vm: + ubuntu2204-vm: + ubuntu2404-vm: + ubuntu2604-vm: diff --git a/extensions/molecule/bootloader/molecule.yml b/extensions/molecule/bootloader/molecule.yml new file mode 100644 index 00000000..1e47cbff --- /dev/null +++ b/extensions/molecule/bootloader/molecule.yml @@ -0,0 +1 @@ +# Molecule scenario marker diff --git a/extensions/molecule/bootloader/verify.yml b/extensions/molecule/bootloader/verify.yml new file mode 100644 index 00000000..e357a57f --- /dev/null +++ b/extensions/molecule/bootloader/verify.yml @@ -0,0 +1,88 @@ +# The kernel command line can only be observed on the running kernel, and it only changes on a +# reboot. The scenario therefore reboots the host once, on the run where the option is still +# missing from /proc/cmdline, and asserts the running kernel came up with it. verify runs twice +# (after converge and after idempotence, see the test_sequence in config.yml); on the second run +# the host already carries the option, so it neither expects a reboot request nor reboots again. +- name: 'Verify the kernel command line carries the configured option' + hosts: 'systems_under_test' + tasks: + + # Persistent state, visible before the reboot: on the Red Hat family the role writes the + # boot entries with grubby, on the Debian family update-grub regenerates grub.cfg. + - name: 'grubby --info=ALL' + ansible.builtin.command: 'grubby --info=ALL' + register: '__molecule__grubby_info_result' + changed_when: false + when: + - 'ansible_facts["os_family"] == "RedHat"' + + - name: 'Assert that every boot entry has psi=1 on its command line' + ansible.builtin.assert: + that: '__molecule__grubby_info_result["stdout_lines"] + | select("match", "^args=") + | reject("search", "psi=1") + | list | length == 0' + when: + - 'ansible_facts["os_family"] == "RedHat"' + + - name: 'grep "^[[:space:]]*linux" /boot/grub/grub.cfg' + ansible.builtin.command: 'grep --extended-regexp "^[[:space:]]*linux" /boot/grub/grub.cfg' + register: '__molecule__grub_cfg_linux_result' + changed_when: false + when: + - 'ansible_facts["os_family"] == "Debian"' + + - name: 'Assert that every menu entry has psi=1 on its command line' + ansible.builtin.assert: + that: '__molecule__grub_cfg_linux_result["stdout_lines"] + | reject("search", "psi=1") + | list | length == 0' + when: + - 'ansible_facts["os_family"] == "Debian"' + + - name: 'slurp /proc/cmdline' + ansible.builtin.slurp: + src: '/proc/cmdline' + register: '__molecule__proc_cmdline_result' + + # Only true right after converge changed the boot loader configuration. The role requests the + # reboot instead of performing it, so the request has to be waiting in the schedule_reboot spool. + - name: 'Verify the pending reboot and apply it' + when: '"psi=1" not in (__molecule__proc_cmdline_result["content"] | ansible.builtin.b64decode)' + block: + + - name: 'stat /run/schedule-reboot/bootloader' + ansible.builtin.stat: + path: '/run/schedule-reboot/bootloader' + register: '__molecule__reboot_request_stat_result' + + - name: 'Assert that the role requested a reboot' + ansible.builtin.assert: + that: '__molecule__reboot_request_stat_result["stat"]["exists"] | bool' + + - name: 'systemctl reboot' + ansible.builtin.reboot: # yamllint disable-line rule:empty-values + + - name: 'slurp /proc/cmdline' + ansible.builtin.slurp: + src: '/proc/cmdline' + register: '__molecule__proc_cmdline_result' + + - name: 'Assert that the running kernel booted with psi=1' + ansible.builtin.assert: + that: '"psi=1" in (__molecule__proc_cmdline_result["content"] | ansible.builtin.b64decode)' + + # The option is only worth setting if the kernel acts on it. RHEL 8 ships kernels without + # pressure stall information, so this is checked everywhere else. + - name: 'slurp /proc/pressure/cpu' + ansible.builtin.slurp: + src: '/proc/pressure/cpu' + register: '__molecule__proc_pressure_cpu_result' + when: + - 'not (ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution_major_version"] | int < 9)' + + - name: 'Assert that the kernel reports pressure stall information' + ansible.builtin.assert: + that: '(__molecule__proc_pressure_cpu_result["content"] | ansible.builtin.b64decode) is search("^some ")' + when: + - 'not (ansible_facts["os_family"] == "RedHat" and ansible_facts["distribution_major_version"] | int < 9)' diff --git a/playbooks/README.md b/playbooks/README.md index a17fbc30..b0e73d4b 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -99,6 +99,16 @@ Calls the following roles (in order): * [blocky](https://github.com/Linuxfabrik/lfops/tree/main/roles/blocky) +## bootloader.yml + +Calls the following roles (in order): + +* [postfix](https://github.com/Linuxfabrik/lfops/tree/main/roles/postfix): `bootloader__skip_postfix` +* [mailto_root](https://github.com/Linuxfabrik/lfops/tree/main/roles/mailto_root): `bootloader__skip_mailto_root` +* [schedule_reboot](https://github.com/Linuxfabrik/lfops/tree/main/roles/schedule_reboot): `bootloader__skip_schedule_reboot` +* [bootloader](https://github.com/Linuxfabrik/lfops/tree/main/roles/bootloader) + + ## borg_local.yml Calls the following roles (in order): diff --git a/playbooks/all.yml b/playbooks/all.yml index 5e80cda7..d08c3986 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -9,6 +9,7 @@ - import_playbook: 'audit.yml' - import_playbook: 'bind.yml' - import_playbook: 'blocky.yml' +- import_playbook: 'bootloader.yml' - import_playbook: 'borg_local.yml' - import_playbook: 'chromium_headless.yml' - import_playbook: 'chrony.yml' diff --git a/playbooks/bootloader.yml b/playbooks/bootloader.yml new file mode 100644 index 00000000..b0a4ac5f --- /dev/null +++ b/playbooks/bootloader.yml @@ -0,0 +1,49 @@ +- name: 'Playbook linuxfabrik.lfops.bootloader' + hosts: + - 'lfops_bootloader' + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + - role: 'linuxfabrik.lfops.postfix' + postfix__aliases__dependent_var: '{{ + mailto_root__postfix__aliases__dependent_var + }}' + postfix__sender_canonicals__dependent_var: '{{ + mailto_root__postfix__sender_canonicals__dependent_var + }}' + when: + - 'not bootloader__skip_postfix | d(false)' + + - role: 'linuxfabrik.lfops.mailto_root' + when: + - 'not bootloader__skip_mailto_root | d(false)' + + # deployed before the bootloader role, so the schedule-reboot command is in place when a + # changed kernel command line requests a reboot + - role: 'linuxfabrik.lfops.schedule_reboot' + when: + - 'not bootloader__skip_schedule_reboot | d(false)' + + - role: 'linuxfabrik.lfops.bootloader' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' diff --git a/roles/bootloader/README.md b/roles/bootloader/README.md new file mode 100644 index 00000000..4bd56557 --- /dev/null +++ b/roles/bootloader/README.md @@ -0,0 +1,113 @@ +# Ansible Role linuxfabrik.lfops.bootloader + +This role manages the kernel command line of a host, for parameters that only take effect at boot time. + +On the Red Hat family the boot entries are written with `grubby`. Debian and Ubuntu do not package `grubby`, so there the role deploys a GRUB drop-in of its own and regenerates the boot loader configuration. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* Red Hat family: options are applied to every boot entry of the host (`grubby --update-kernel=ALL`), so the running kernel and every kernel still installed alongside it carry the same command line. `grubby` keeps `GRUB_CMDLINE_LINUX` in `/etc/default/grub` in sync while doing so, appending only the managed options and leaving the rest of the file alone. Nothing else in `/etc/default/grub` and nothing in `grub.cfg` is touched. +* Debian family: the options are written to `/etc/default/grub.d/z00-lfops.cfg` and `update-grub` regenerates `/boot/grub/grub.cfg` from it. `grub-mkconfig` sources `/etc/default/grub` first and every `/etc/default/grub.d/*.cfg` after it, so the drop-in wins without the packaged configuration file ever being edited, and it appends to whatever `GRUB_CMDLINE_LINUX` already holds instead of replacing it (the sourcing order was read from the `grub-mkconfig` of grub-common 2.12-9+deb13u2 on Debian 13, 2.12-1ubuntu7.3 on Ubuntu 24.04 and 2.14-2ubuntu2.1 on Ubuntu 26.04). Once no option is left to set, the drop-in is removed instead of being left behind empty. +* A configured option counts as present only when **every** boot entry carries it, and it is compared as a whole word with the option escaped, so an option containing a dot matches a dot. +* A run against a host that already carries the configured command line changes nothing and reports no change, and it neither requests a reboot nor touches any file. Changing the value of an option that is already set replaces it rather than adding a second one. +* `--check` changes nothing. The dry run reads the current boot entries and reports what it would add or remove. +* The change only takes effect on the next boot. When the [schedule_reboot](https://github.com/Linuxfabrik/lfops/tree/main/roles/schedule_reboot) mechanism is deployed, a changed command line requests a reboot at the next maintenance window (spool entry `bootloader`). Without it, the role only prints a message and leaves the reboot to the operator. +* On the Red Hat family a kernel installed later inherits the command line from the running kernel. `kernel-install` builds the boot entry of a new kernel from `/etc/kernel/cmdline`, from `/usr/lib/kernel/cmdline`, or, when neither exists, from `/proc/cmdline` of the running kernel (verified against `/usr/lib/kernel/install.d/20-grub.install` on Rocky 9). A kernel installed between the change and the reboot therefore still comes up without the new options; run the role again afterwards. On the Debian family this cannot happen, because installing a kernel regenerates `/boot/grub/grub.cfg` from the drop-in. +* The role manages the kernel command line only. It does not add, remove or reorder boot entries, does not change the boot loader timeout, and does not manage the GRUB password. + + +## Known Limitations + +* GRUB 2 only. Hosts booted by zipl or systemd-boot are not supported. +* Debian family: `state: 'absent'` only drops an option from the command line this role writes. An option that comes from `/etc/default/grub` or from another drop-in stays, because the role never edits files it does not own. On the Red Hat family the same option is removed with `grubby --remove-args`. + + +## Dependent Roles + +Any [LFOps playbook](https://github.com/Linuxfabrik/lfops/blob/main/playbooks/README.md) that installs this role runs these for you. Optional ones can be disabled via the playbook's skip variables. + +* Optional: the reboot mechanism should be in place (role: [linuxfabrik.lfops.schedule_reboot](https://github.com/Linuxfabrik/lfops/tree/main/roles/schedule_reboot)), so a changed kernel command line reboots the host at the maintenance window instead of waiting for a manual reboot. + + +## Requirements + +* The host is booted by GRUB 2. +* Red Hat family: `grubby` is installed. It is part of every GRUB installation there, since `kernel-install` relies on it. +* Debian family: `grub2-common` is installed. It provides `update-grub`, which the role calls. + + +## Tags + +`bootloader` + +* Configures the kernel command line. +* Requests a reboot when the kernel command line changed. +* Triggers: none. + + +## Optional Role Variables + +These variables are intended to be used in a host / group variable file in the Ansible inventory. Note that the group variable can only be used in one group at a time. + +`bootloader__cmdline_options__host_var` / `bootloader__cmdline_options__group_var` + +* Kernel command line options. An option that is already present with a different value is overwritten. On the Debian family the options end up in `GRUB_CMDLINE_LINUX`, so they apply to the recovery entries as well. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `name`: + + * Mandatory. Name of the option, for example `psi`. + * Type: String. + + * `value`: + + * Optional. Value of the option. Omit it for options that stand on their own, for example `quiet`. Quote a value YAML reads as a boolean, `'on'` and `'off'` among them, otherwise it reaches the command line as `True` or `False`. + * Type: String or Number. + + * `state`: + + * Optional. Whether the option is added to or removed from the kernel command line. One of `present` or `absent`. On the Debian family see "Known Limitations". + * Type: String. + * Default: `'present'` + +Example: +```yaml +# optional +bootloader__cmdline_options__group_var: + - name: 'psi' + value: 1 + - name: 'quiet' + - name: 'nosmt' + state: 'absent' +``` + + +## Troubleshooting + +**The option is configured, but `/proc/cmdline` does not contain it** + +* The host has not been rebooted since the change. Check the boot entries with `grubby --info=ALL` respectively `grep linux /boot/grub/grub.cfg`; they carry the new command line right away, `/proc/cmdline` only after the reboot. + +**On a Red Hat-family host a newly installed kernel boots without the configured options** + +* The kernel was installed while the change was still pending a reboot, so it inherited the command line of the running kernel. Run the role again to update the entry of the new kernel. + +**On a Debian-family host an option is still on the command line although it is set to `state: 'absent'`** + +* The option comes from `/etc/default/grub` or from another drop-in in `/etc/default/grub.d/`, which this role does not touch. Remove it there. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/bootloader/defaults/main.yml b/roles/bootloader/defaults/main.yml new file mode 100644 index 00000000..7f779279 --- /dev/null +++ b/roles/bootloader/defaults/main.yml @@ -0,0 +1,12 @@ +bootloader__cmdline_options__combined_var: '{{ ( + bootloader__cmdline_options__role_var + + bootloader__cmdline_options__dependent_var + + bootloader__cmdline_options__group_var + + bootloader__cmdline_options__host_var + ) | linuxfabrik.lfops.combine_lod + }}' + +bootloader__cmdline_options__dependent_var: [] +bootloader__cmdline_options__group_var: [] +bootloader__cmdline_options__host_var: [] +bootloader__cmdline_options__role_var: [] diff --git a/roles/bootloader/meta/argument_specs.yml b/roles/bootloader/meta/argument_specs.yml new file mode 100644 index 00000000..efcfafb6 --- /dev/null +++ b/roles/bootloader/meta/argument_specs.yml @@ -0,0 +1,30 @@ +argument_specs: + main: + options: + + bootloader__cmdline_options__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: >- + Kernel command line options to apply (`name`/`value`/`state`). + Dependent-role injection. + + bootloader__cmdline_options__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: >- + Kernel command line options to apply (`name`/`value`/`state`). + Group-level override. + + bootloader__cmdline_options__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: >- + Kernel command line options to apply (`name`/`value`/`state`). + Host-level override. diff --git a/roles/bootloader/tasks/Debian.yml b/roles/bootloader/tasks/Debian.yml new file mode 100644 index 00000000..9b2de079 --- /dev/null +++ b/roles/bootloader/tasks/Debian.yml @@ -0,0 +1,44 @@ +# grub-mkconfig sources /etc/default/grub first and every /etc/default/grub.d/*.cfg after it, so +# a drop-in overrides the packaged configuration without editing a file the package owns. +# Verified against grub-common 2.12-9+deb13u2 on Debian 13 and 2.12-1ubuntu7.3 on Ubuntu 24.04. +- name: 'Deploy {{ __bootloader__grub_dropin_path }}' + ansible.builtin.template: + backup: true + src: 'etc/default/grub.d/z00-lfops.cfg.j2' + dest: '{{ __bootloader__grub_dropin_path }}' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__bootloader__grub_dropin_result' + when: + - '__bootloader__wanted_options | length > 0' + +# nothing left to set means the drop-in has nothing to say, so it goes away instead of staying +# behind as an empty file +- name: 'rm {{ __bootloader__grub_dropin_path }}' + ansible.builtin.file: + path: '{{ __bootloader__grub_dropin_path }}' + state: 'absent' + register: '__bootloader__grub_dropin_removed_result' + when: + - '__bootloader__wanted_options | length == 0' + +# update-grub is a wrapper that execs grub-mkconfig, which in turn calls grub-probe and friends, +# all of them in /usr/sbin. An unprivileged login has no /usr/sbin in its PATH, and both `su` and +# a sudoers file without secure_path hand that PATH to the task, so the command needs one of its +# own. Verified on Debian 13, where the run otherwise ends in +# "/usr/sbin/update-grub: 3: exec: grub-mkconfig: not found". +- name: '{{ __bootloader__update_grub_command }}' + ansible.builtin.command: + cmd: '{{ __bootloader__update_grub_command }}' + environment: + PATH: '/usr/sbin:/usr/bin:/sbin:/bin' + changed_when: true + when: + - '(__bootloader__grub_dropin_result is changed) or (__bootloader__grub_dropin_removed_result is changed)' + +- name: 'Remember that the kernel command line changed' + ansible.builtin.set_fact: + __bootloader__reboot_needed: true + when: + - '(__bootloader__grub_dropin_result is changed) or (__bootloader__grub_dropin_removed_result is changed)' diff --git a/roles/bootloader/tasks/RedHat.yml b/roles/bootloader/tasks/RedHat.yml new file mode 100644 index 00000000..d3fcf553 --- /dev/null +++ b/roles/bootloader/tasks/RedHat.yml @@ -0,0 +1,63 @@ +# grubby writes the boot entries on the Red Hat family, so the role calls it directly instead of +# going through fedora.linux_system_roles.bootloader. That role rewrites GRUB_TIMEOUT in +# /etc/default/grub and every `set timeout=` in grub.cfg on each run, which also replaces the +# timeouts of the menu_auto_hide and menu_show_once blocks (`set timeout="${menu_show_once_timeout}"` +# becomes a literal), and its bootloader_settings module declares supports_check_mode without ever +# reading module.check_mode, so a --check run rewrites the boot entries for real. +# Verified against grubby 8.40 on Rocky 9. +- name: 'grubby --info=ALL' + ansible.builtin.command: + cmd: 'grubby --info=ALL' + register: '__bootloader__grubby_info_result' + changed_when: false + check_mode: false # read-only, and it has to run in check mode too to report what would change + +# The comparison covers every boot entry, not just the default one, and the option is escaped +# before it goes into the pattern, so a dot in an option name matches a dot. +- name: 'Determine the options missing from at least one boot entry' + ansible.builtin.set_fact: + __bootloader__missing_options: '{{ __bootloader__missing_options + [item] }}' + loop: '{{ __bootloader__wanted_options }}' + when: + - '__bootloader__current_args | reject("search", "(^| )" ~ (item | regex_escape) ~ "( |$)") + | list | length > 0' + +- name: 'Determine the options still set on at least one boot entry' + ansible.builtin.set_fact: + __bootloader__superfluous_options: '{{ __bootloader__superfluous_options + [item] }}' + loop: '{{ __bootloader__unwanted_option_names }}' + when: + - '__bootloader__current_args | select("search", "(^| )" ~ (item | regex_escape) ~ "(=| |$)") + | list | length > 0' + +# a `command` task is skipped in check mode, so without this a dry run would report nothing to do +# although there is. This reports what would happen and marks the run as changed. +- name: 'Report the pending kernel command line change' + ansible.builtin.debug: + msg: 'grubby --update-kernel=ALL would add [{{ __bootloader__missing_options | join(" ") }}] + and remove [{{ __bootloader__superfluous_options | join(" ") }}]' + changed_when: true + when: + - 'ansible_check_mode | bool' + - '(__bootloader__missing_options | length > 0) or (__bootloader__superfluous_options | length > 0)' + +- name: 'grubby --update-kernel=ALL --remove-args' + ansible.builtin.command: + cmd: 'grubby --update-kernel=ALL --remove-args={{ __bootloader__superfluous_options + | join(" ") | quote }}' + changed_when: true + when: + - '__bootloader__superfluous_options | length > 0' + +- name: 'grubby --update-kernel=ALL --args' + ansible.builtin.command: + cmd: 'grubby --update-kernel=ALL --args={{ __bootloader__missing_options | join(" ") | quote }}' + changed_when: true + when: + - '__bootloader__missing_options | length > 0' + +- name: 'Remember that the kernel command line changed' + ansible.builtin.set_fact: + __bootloader__reboot_needed: true + when: + - '(__bootloader__missing_options | length > 0) or (__bootloader__superfluous_options | length > 0)' diff --git a/roles/bootloader/tasks/main.yml b/roles/bootloader/tasks/main.yml new file mode 100644 index 00000000..ebafd84c --- /dev/null +++ b/roles/bootloader/tasks/main.yml @@ -0,0 +1,55 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + - ansible.builtin.debug: + msg: + - 'Combined cmdline_options:' + - '{{ bootloader__cmdline_options__combined_var }}' + + # reset on every run, so a second run of the role in the same play does not inherit the + # findings of the first one + - name: 'Reset the state of a previous run' + ansible.builtin.set_fact: + __bootloader__missing_options: [] + __bootloader__reboot_needed: false + __bootloader__superfluous_options: [] + + # include_tasks is required here because import_tasks cannot handle dynamic variables. + - name: 'Perform platform specific tasks' + ansible.builtin.include_tasks: '{{ ansible_facts["os_family"] }}.yml' + + - name: 'stat /usr/local/sbin/schedule-reboot' + ansible.builtin.stat: + path: '/usr/local/sbin/schedule-reboot' + register: '__bootloader__schedule_reboot_stat_result' + + - name: 'schedule-reboot bootloader' + ansible.builtin.command: + argv: + - 'schedule-reboot' + - 'bootloader' + - 'kernel command line changed' + register: '__bootloader__schedule_reboot_result' + changed_when: '__bootloader__schedule_reboot_result["rc"] == 0' + when: + - '__bootloader__reboot_needed | bool' + - '__bootloader__schedule_reboot_stat_result["stat"]["exists"] | bool' + + - ansible.builtin.debug: + msg: 'The kernel command line has changed. Please reboot the server manually to apply it.' + when: + - '__bootloader__reboot_needed | bool' + - 'not __bootloader__schedule_reboot_stat_result["stat"]["exists"] | bool' + + tags: + - 'bootloader' diff --git a/roles/bootloader/templates/etc/default/grub.d/z00-lfops.cfg.j2 b/roles/bootloader/templates/etc/default/grub.d/z00-lfops.cfg.j2 new file mode 100644 index 00000000..12de000a --- /dev/null +++ b/roles/bootloader/templates/etc/default/grub.d/z00-lfops.cfg.j2 @@ -0,0 +1,7 @@ +# {{ ansible_managed }} +# 20260828 + +# Sourced by grub-mkconfig after /etc/default/grub, so the setting below wins. Run +# `update-grub` after changing this file to regenerate /boot/grub/grub.cfg. +# Appended to whatever /etc/default/grub set, so the packaged parameters are kept. +GRUB_CMDLINE_LINUX="$GRUB_CMDLINE_LINUX {{ __bootloader__wanted_options | join(' ') }}" diff --git a/roles/bootloader/vars/Debian.yml b/roles/bootloader/vars/Debian.yml new file mode 100644 index 00000000..52bb50e9 --- /dev/null +++ b/roles/bootloader/vars/Debian.yml @@ -0,0 +1,4 @@ +__bootloader__grub_dropin_path: '/etc/default/grub.d/z00-lfops.cfg' +# absolute path on purpose: /usr/sbin is not in the PATH of an unprivileged login, and +# `become_method: su` keeps that PATH, so the bare command name is not found there +__bootloader__update_grub_command: '/usr/sbin/update-grub' diff --git a/roles/bootloader/vars/Ubuntu.yml b/roles/bootloader/vars/Ubuntu.yml new file mode 100644 index 00000000..52bb50e9 --- /dev/null +++ b/roles/bootloader/vars/Ubuntu.yml @@ -0,0 +1,4 @@ +__bootloader__grub_dropin_path: '/etc/default/grub.d/z00-lfops.cfg' +# absolute path on purpose: /usr/sbin is not in the PATH of an unprivileged login, and +# `become_method: su` keeps that PATH, so the bare command name is not found there +__bootloader__update_grub_command: '/usr/sbin/update-grub' diff --git a/roles/bootloader/vars/main.yml b/roles/bootloader/vars/main.yml new file mode 100644 index 00000000..04840413 --- /dev/null +++ b/roles/bootloader/vars/main.yml @@ -0,0 +1,31 @@ +# The options that have to be on the kernel command line, rendered the way they appear there: +# 'name=value', or a bare 'name' for options that stand on their own. Options with and without a +# value are assembled differently, then concatenated. +__bootloader__present_options: '{{ + (bootloader__cmdline_options__combined_var | selectattr("state", "undefined") | list) + + (bootloader__cmdline_options__combined_var | selectattr("state", "defined") + | selectattr("state", "ne", "absent") | list) + }}' +__bootloader__wanted_options: '{{ + (__bootloader__present_options | selectattr("value", "defined") | map(attribute="name") | list + | zip(__bootloader__present_options | selectattr("value", "defined") + | map(attribute="value") | list) + | map("join", "=") | list) + + (__bootloader__present_options | selectattr("value", "undefined") | map(attribute="name") | list) + }}' + +# The names of the options that have to be off the kernel command line, whatever their value is. +__bootloader__unwanted_option_names: '{{ + bootloader__cmdline_options__combined_var | selectattr("state", "defined") + | selectattr("state", "eq", "absent") | map(attribute="name") | list + }}' + +# The args="..." line of every boot entry, as reported by `grubby --info=ALL`. The quotes are +# turned into spaces so that a search for a whole option matches at the ends of the line as well. +# Only meaningful once tasks/RedHat.yml has registered that command. +__bootloader__current_args: "{{ + __bootloader__grubby_info_result['stdout_lines'] | d([]) + | select('match', '^args=') + | map('regex_replace', '\"', ' ') + | list + }}"