Rebooting every host you patched is wasteful. Rebooting no host you
patched is dangerous. The interesting work is in between, and it starts
with a question the host can answer for itself: do you need a
reboot?
The two distribution families answer it by entirely different
mechanisms, and neither one is a superset of the other. Writing a play
that treats one as universal produces a fleet where half the hosts are
never rebooted and the run log says everything is fine.
Debian and Ubuntu: a file, if something wrote it
On Debian-family hosts, a package that needs a reboot creates
/var/run/reboot-required. A companion file,
/var/run/reboot-required.pkgs, lists the packages that asked for it,
one per line.
Both live under /run on modern systems — /var/run is a symlink — so
they are tmpfs files that vanish on boot. That is the property that
makes them correct: the flag cannot survive the reboot it was asking
for.
Read-only / Safeasking a Debian-family host whether it needs a reboot— Read-only. stat inspects the filesystem and changes nothing. slurp reads a file's contents back to the controller; it writes nothing on the host.
- name: Check for the Debian-family reboot flag
ansible.builtin.stat:
path: /var/run/reboot-required
get_checksum: false
register: reboot_flag
when: ansible_facts.os_family == 'Debian'
- name: Read which packages requested the reboot
ansible.builtin.slurp:
src: /var/run/reboot-required.pkgs
register: reboot_pkgs_raw
when:
- ansible_facts.os_family == 'Debian'
- reboot_flag.stat.exists | default(false)
failed_when: false
- name: Record the reason
ansible.builtin.set_fact:
reboot_required: '{{ reboot_flag.stat.exists | default(false) }}'
reboot_reason: >-
{{ (reboot_pkgs_raw.content | default('') | b64decode).split('\n')
| reject('equalto', '') | list }}
when: ansible_facts.os_family == 'Debian'
get_checksum: false is worth setting. stat defaults it to true,
and checksumming a file you only want the existence of is pure cost —
trivial here, and not trivial when the same habit is applied to a large
file later.
Read-only / Safeproving the detection mechanism is present before trusting it— Read-only. Fails the host if the tool that creates the reboot flag is absent, converting a silent false negative into a loud precondition failure.
- name: Confirm the reboot-required mechanism is installed
ansible.builtin.stat:
path: /usr/share/update-notifier/notify-reboot-required
get_checksum: false
register: notify_helper
when: ansible_facts.os_family == 'Debian'
- name: Refuse to trust reboot detection on a host that cannot signal it
ansible.builtin.fail:
msg: >-
{{ inventory_hostname }} has no notify-reboot-required helper, so
/var/run/reboot-required will never appear regardless of what is
patched. Install update-notifier-common or exclude this host from
flag-based detection.
when:
- ansible_facts.os_family == 'Debian'
- not (notify_helper.stat.exists | default(false))
Debian-family hosts may additionally have needrestart, which answers a
different and complementary question: which running daemons are using
libraries that have been replaced. Its -b option enables batch mode,
documented as “don’t restart anything and produce machine-readable
output”, and -r l sets list-only restart mode. That tells you about
service restarts, not about kernel reboots, and the two decisions are
genuinely separate.
RHEL, Rocky and Alma: an exit status, and it is inverted
The RHEL-family mechanism is needs-restarting, from
dnf-plugins-core. With -r it answers only the reboot question, and
it answers by exit code.
The documentation is explicit, and the convention is backwards from
everything else in a playbook:
Only report whether a reboot is required (exit code 1) or not (exit
code 0).
Exit 1 means a reboot is needed. Exit 0 means it is not. To Ansible,
a non-zero return code is a task failure, so a naive task fails on
precisely the hosts you were trying to identify.
Read-only / Safehandling the inverted exit status— Read-only. needs-restarting -r inspects the RPM database and running processes; it restarts nothing. failed_when: false stops Ansible from treating the informative exit code as a failure.
- name: Ask whether a reboot is required
ansible.builtin.command:
argv: [dnf, needs-restarting, '-r']
register: needs_reboot
changed_when: false
failed_when: false
when: ansible_facts.os_family == 'RedHat'
- name: Record the decision
ansible.builtin.set_fact:
reboot_required: '{{ needs_reboot.rc == 1 }}'
when: ansible_facts.os_family == 'RedHat'
Three deliberate choices in that task.
changed_when: false because asking a question is not a change. A
command task reports changed by default, and an inspection task that
colours the run yellow trains people to ignore yellow.
failed_when: false because the exit code is data, not a verdict.
Without it, exit 1 fails the host and — under max_fail_percentage: 0 —
stops the play, on the hosts that most needed patching.
argv: rather than a command string because the argument list is
explicit and nothing goes near a shell. The command module does not
invoke a shell anyway, and argv makes that visible.
needs-restarting -s (or --services) lists only the affected systemd
services, which is the RHEL-family counterpart of what needrestart
does on Debian: the service-restart question, distinct from the reboot
question.
The comparison that works on both families
The running kernel and the installed kernel are two different things,
and comparing them requires no distribution-specific tooling at all.
ansible_facts.kernel is the running kernel — verified against
ansible-core 2.21.3, where a setup run reported
ansible_kernel: 7.0.0-29-generic, the same string uname -r prints.
It is gathered at the start of the play, which means it is the kernel
that was running when facts were gathered, and after a reboot you
must gather again to see the new one.
ansible.builtin.package_facts gives you what is installed. Its
structure is the thing to get right: ansible_facts.packages is a
dictionary keyed by package name, and each value is a list, because
a host can legitimately have several versions of the same package —
which is exactly the situation kernels are always in.
Read-only / Saferunning kernel versus newest installed kernel— Read-only. package_facts enumerates the package database and sets facts; it installs and removes nothing. Works on both families because both report a kernel package.
- name: Collect the installed package inventory
ansible.builtin.package_facts:
manager: auto
- name: Work out the newest installed kernel
ansible.builtin.set_fact:
kernel_package: >-
{{ 'linux-image-generic' if ansible_facts.os_family == 'Debian'
else 'kernel' }}
- name: Compare the running kernel against what is installed
ansible.builtin.debug:
msg: >-
running={{ ansible_facts.kernel }}
installed={{ ansible_facts.packages[kernel_package]
| default([]) | map(attribute='version') | list }}
For the specific question “is the running kernel the newest installed
one”, ansible_facts.kernel compared against the sorted installed
versions answers it directly, and it answers on a minimal host with no
update-notifier-common and no dnf-plugins-core. That independence is
what makes it the backstop.
Recording the decision
A reboot decision that exists only as a when: condition is invisible
afterwards. Six weeks later, asked why db04 was not rebooted in the
March window, nobody can answer.
Read-only / Safemaking the decision legible in the run log— Read-only. Emits one line per host stating the decision and its basis, which is what a change record and a post-incident reconstruction both need.
The three states that matter are true, false and unknown. Most
implementations have only the first two, and they encode “the detection
did not work” as false, which is the same value as “the host is fine”.
Knowledge check
Knowledge check · 4 questions
Q1. A task runs `dnf needs-restarting -r` with the command module and no failed_when. On which hosts does the task fail?
Q2. A patch play stats /var/run/reboot-required on every host in a mixed Debian and Rocky fleet and reboots where it exists. What is the most serious defect?
Q3. Why is comparing ansible_facts.kernel against the installed kernel packages a useful backstop alongside the per-family detection mechanisms? Select all that apply.
Q4. If /var/run/reboot-required is absent on a Debian host after a kernel upgrade, the host definitely does not need a reboot.
Passing score: 75%. Answers are checked in this browser.