The first real run failed on all three nodes: fatal: [k3s-prod-1]: "No package matching 'update-notifier-common' is available" update-notifier-common is an UBUNTU package and does not exist in Debian -- `apt-cache policy` returns Candidate: (none) on trixie. The role asserted it "owns the hook that creates /var/run/reboot-required", which is true on Ubuntu and false here, so the role could never have worked. Three further measurements settled the replacement: notify-reboot-required is absent; NO linux-image-* postinst references it (invoking it is an Ubuntu patch, so dropping a script at that path would never be called); and no hook under apt.conf.d or /etc/kernel mentions reboot-required. Debian trixie ships no mechanism at all. So the role now provides it: * /usr/local/sbin/k3s-notify-reboot-required -- touches the sentinel when the running kernel is not the newest installed, using `linux-version` (from linux-base, a dependency of every linux-image, measured present) because kernel version ordering is not string ordering. * /etc/apt/apt.conf.d/99-k3s-reboot-required -- DPkg::Post-Invoke, `|| true` so it can never abort an apt transaction. /run is tmpfs (measured), so the sentinel self-clears on boot; no cleanup task. The verify assert was ALSO weaker than it looked: a `find` glob for *update-notifier* / *unattended-upgrades* under apt.conf.d would have matched Debian's own unattended-upgrades conffile and reported "writer present" while none existed. It now stats the two exact paths this role owns and requires the script to be executable. Newly verified: the script ran on prod-1 as unprivileged k3s under sh -x -- running=newest=7.1.8+deb13-amd64, took the equal branch, exited 0, wrote nothing. Correct. sh -n and bash -n clean. Still unverified: the WRITING half never executes while running == newest, so the sentinel has not been seen created and kured has not been seen react. The honest test is the next kernel upgrade. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
roles/unattended_upgrades
Automatic Debian security patching on the k3s nodes. The kernel is excluded, k3s is never
restarted, and reboots are delegated to kured (platform/kured).
- Playbook:
playbooks/unattended-upgrades.yml - Enabled by:
unattended_upgrades_enabled— role defaultfalse, settruefor prod ininventory/group_vars/k3s_prod/vars.yml - Companion component:
platform/kured(the reboot half). Neither is useful alone — see "The two halves" below. - Every decision and its reasoning:
defaults/main.yml. That file is the argument; this one is the orientation.
The two halves, and why they ship together
| what it does | what it must NOT do | |
|---|---|---|
| this role (Ansible, host) | installs security updates continuously; leaves /var/run/reboot-required behind when one needs a reboot |
upgrade the kernel; restart k3s; reboot |
| kured (Flux, cluster) | cordons, drains, reboots and uncordons one node at a time when that file exists | decide whether a reboot is needed |
Deploying either alone produces a mechanism that looks healthy and does nothing:
-
kured alone watches a file that nothing on Debian ever creates. Measured 2026-10-18, after a first attempt to install Ubuntu's
update-notifier-commonfailed on all three nodes with "No package matching 'update-notifier-common' is available":apt-cache policy update-notifier-common→Candidate: (none)— the package does not exist in Debian; it is Ubuntu-only./usr/share/update-notifier/notify-reboot-required→ absent.- No
linux-image-*postinst referencesnotify-reboot-required— calling it is an Ubuntu patch, so even dropping a script at that path would never be invoked. - No hook under
/etc/apt/apt.conf.dor/etc/kernel/mentionsreboot-required.
So this role ships the writer itself (
/usr/local/sbin/k3s-notify-reboot-requiredplus aDPkg::Post-Invokehook) rather than installing a package. Without it kured would sit at 1/1 Running reportingkured_reboot_required=0forever. -
this role alone installs patches that a long-running process never picks up, and when a reboot is needed there is nothing safe to take it.
Automatic-Reboot "true"is not the answer: three control-plane nodes with etcd quorum 2/3, each rebooting at its own time, is how the cluster is lost.
What it was replacing: nothing
Measured on all three prod nodes, 2026-10-17:
unattended-upgrades |
not installed |
update-notifier-common |
not installable — Ubuntu-only, Candidate: (none) on trixie |
needrestart |
not installed |
apt-mark showhold |
empty — no package held on any node |
/etc/apt/preferences.d/ |
empty — no apt pin declares the backports-kernel intent |
/var/run/reboot-required |
absent, and uncreatable |
These nodes had received no automatic patching since provisioning.
The three decisions
1. Security only
Allowed origins are label=Debian-Security and nothing else. trixie-updates is excluded
deliberately (not overlooked) — it carries non-security fixes that are useful but are the archive
through which an unreviewed change would most plausibly arrive on a control-plane node. The knob to
add it is unattended_upgrades_include_updates.
2. The kernel is excluded twice over
This is the highest-risk part of the change. These HP EliteDesk 800 G5 minis publish a corrupt
EFI Memory Attributes Table on R21 firmware; Linux < 6.13 does not validate it before reserving
memory from its contents, and k3s-prod-2 twice booted with 2.6 GiB of 16 GB. Upstream
8fbe4c49c0cc (first in v6.13) is the only fix — there is no kernel cmdline workaround. Full
write-up: infra/PROD-PROVISIONING.md §0b and roles/node_baseline/defaults/main.yml:21-51.
Measured 2026-10-17: all three nodes run 7.1.8+deb13-amd64 (7.1.8-1~bpo13+1, i.e. the backports
metapackage), comfortably above the floor — but linux-image-6.12.107+deb13-amd64 and
linux-image-6.12.94+deb13-amd64 are also still installed, below the floor, bootable, and held by
nothing.
So an unattended process must not be able to touch the kernel at all:
- Origin exclusion (structural). The running kernel comes from
o=Debian Backports(measured viaapt-cache policy, pin priority 100 — theNotAutomatic+ButAutomaticUpgradessignature). That origin is not inOrigins-Pattern, so unattended-upgrades cannot see a kernel upgrade at all. - Package blacklist (belt and braces).
^linux-is blacklisted, so even if the origin list is later widened — the most likely future edit to this role, and the one that would silently re-arm the hazard — the kernel is still untouchable.
The role asserts both against apt-config dump (apt's own parse of the whole
/etc/apt/apt.conf.d/ directory) rather than trusting the file it just wrote.
This does not stop the kernel moving. The operator still advances it deliberately with
ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags kernel --limit <host>
which is the only thing that maintains the backports source, the metapackage and the on-node
provenance stamp. node_baseline's own note that apt upgrade keeps the backports kernel current
still holds for an operator-run apt. What changed is that a new unattended actor is forbidden
from it.
Also blacklisted, for the same "operator-only" reason: intel-microcode, amd64-microcode,
grub-*. Microcode and bootloader changes take effect on reboot and alter boot behaviour on the
very firmware that is already misbehaving here.
Remove-Unused-Kernel-Packages is false, against the Debian default: those older kernels are
the documented rollback path, and an unattended process pruning them could remove the fallback an
operator needs in front of a box that will not boot.
3. needrestart restarts services — but never k3s
A security-only policy that restarts nothing patches bytes on disk that no running process reads.
needrestart in mode a restarts services still mapped to a deleted library, which is what makes
the policy actually effective.
k3s.service is blacklisted. Restarting the Kubernetes control plane unattended at 04:00 is a
worse outcome than a delayed library fix; that fix lands at the next kured reboot instead. Measured
2026-10-17, k3s.service is the only matching unit — k3s bundles its own containerd and
kubelet, and neither containerd nor containerd.io is an apt package on these nodes — so this
one entry is complete today.
Schedule
apt-daily at 03:30 and apt-daily-upgrade at 04:00, both ±60m randomised, via systemd drop-ins.
Chosen to land inside kured's 03:00–06:00 reboot window so that a sentinel written by an upgrade
can be acted on the same night rather than ~24h later.
Two traps the role handles explicitly:
/etc/apt/apt.conf.d/20auto-upgradesmust set the fourAPT::Periodic::*counters. Without them the apt-daily timers fire and do nothing — installed, configured, green, and inert.- The timer drop-ins clear
OnCalendar=before setting it. systemd accumulatesOnCalendarentries across drop-ins, so omitting the reset keeps the shipped schedule in addition to ours.
Why a new role rather than extending node_baseline
node_baseline is the repo's only other apt surface and would have been a defensible home. Two
reasons against:
- Single responsibility.
node_baselineis the first-boot floor — it runs before k3s exists and is the thing that makes a node installable. Continuous patching policy is a different lifecycle with a different blast radius, and it wants to be runnable (and re-runnable) without going anywhere near that role's data-disk tasks. - Concurrent work. Other open branches touch
roles/node_baseline/. A new role has nothing to merge.
Nothing here duplicates node_baseline: it does not add apt sources, does not touch the kernel, and
does not manage disks.
Post-merge verification
Running the role is safe on a live cluster at any time — it installs and configures the upgrader, and
never runs apt upgrade, restarts k3s, or reboots.
# Apply (prod). Idempotent; re-runnable.
cd infra/ansible && ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod
# Re-run ONLY the read-only assertions + the per-node pending-reboot summary.
cd infra/ansible && ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod --tags verify
Then, on a node, confirm the three properties that matter (all read-only, no sudo needed for the first two):
ssh [email protected] 'apt-config dump | grep -iE "unattended-upgrade::(automatic-reboot|package-blacklist|origins)"'
ssh [email protected] 'systemctl list-timers apt-daily-upgrade.timer --no-pager'
ssh [email protected] 'ls -la /var/run/reboot-required* 2>&1 | head -2
ls -l /usr/local/sbin/k3s-notify-reboot-required /etc/apt/apt.conf.d/99-k3s-reboot-required 2>&1
dpkg -l needrestart unattended-upgrades | tail -3'
Expect: Automatic-Reboot "false", a blacklist containing ^linux-, an Origins-Pattern
containing Debian-Security and not Backports, a next-elapse around 04:00, and all three
packages ii.
A dry run of what the upgrader would actually do (needs root, so this one is an operator step):
sudo unattended-upgrade --dry-run --debug
The kernel must appear in its blacklist output, not its upgrade list.
What this role cannot tell you
- That a security upgrade has ever succeeded. That is in
/var/log/unattended-upgrades/unattended-upgrades.logon each node, and nothing in this repo or in Prometheus reads it. There is no alert for "automatic patching has silently stopped working". That is a real, known gap — see the handoff entrydecisions/2026-10-17-automated-upgrades.md. - That a reboot actually happened. That is kured's half, and it is alerted on
(
KuredRebootPending,KuredNodeCoverageGap,KuredNotReportinginplatform/kured/alerts.yaml).