From f0436064668d9f65e7523380ad4622f60b345dab Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Tue, 8 Sep 2026 18:02:41 +0200 Subject: [PATCH] Add deliberate update playbook and unattended-upgrades role Regular playbooks now use state: present, so they no longer upgrade packages as a side effect. This adds two separate, explicit mechanisms to keep the fleet patched instead: - update-packages.yml: ad hoc / to-be-scheduled fleet-wide upgrade (safe by default, dist available via -e), plus update-packages-pinned-example.yml as a template for pinning or bumping a single package outside that. - roles/unattended_upgrades: automatic security-only patching via unattended-upgrades, with a scheduled reboot window and mail left disabled pending a configured MTA. Applied to every host in full.yml and server.yml. Also removes a leftover `upgrade: yes` apt task from pi_standard_setup and server_basic_environment that was still doing a full upgrade on every routine run, defeating the point of the state: present switch. Co-Authored-By: Claude Sonnet 5 --- README.md | 32 ++++++++++++ full.yml | 4 ++ justfile | 4 ++ roles/pi_standard_setup/tasks/main.yml | 3 +- roles/server_basic_environment/tasks/main.yml | 3 +- roles/unattended_upgrades/README.md | 9 ++++ roles/unattended_upgrades/defaults/main.yml | 21 ++++++++ roles/unattended_upgrades/tasks/main.yml | 31 ++++++++++++ .../templates/20auto-upgrades.j2 | 7 +++ .../templates/50unattended-upgrades.j2 | 29 +++++++++++ server.yml | 1 + update-packages-pinned-example.yml | 29 +++++++++++ update-packages.yml | 50 +++++++++++++++++++ 13 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 roles/unattended_upgrades/README.md create mode 100644 roles/unattended_upgrades/defaults/main.yml create mode 100644 roles/unattended_upgrades/tasks/main.yml create mode 100644 roles/unattended_upgrades/templates/20auto-upgrades.j2 create mode 100644 roles/unattended_upgrades/templates/50unattended-upgrades.j2 create mode 100644 update-packages-pinned-example.yml create mode 100644 update-packages.yml diff --git a/README.md b/README.md index e48a0ff..6257a9c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Pis (audio players, sensors, music mouse, etc.) plus one home server. runs kept around for specific hosts or one-off tasks. - `roles/` — one role per piece of functionality (audio backends, sensors, bluetooth monitoring, server basics, etc.). Each has a short `README.md`. +- `update-packages.yml` — deliberate, fleet-wide package update (see + "Keeping packages up to date" below). `update-packages-pinned-example.yml` + is a template for pinning or bumping a single package outside that. - `lookup_plugins/keepass.py` — custom lookup plugin that fetches secrets (device passwords, wifi passphrase) from a running KeePassXC instance via its browser-integration protocol, instead of storing them in the repo. @@ -43,3 +46,32 @@ ansible-playbook full.yml --limit `ansible.cfg` points Ansible at `inventory.yml` and `roles/` by default, so no extra flags are needed for those. + +## Keeping packages up to date + +Regular playbook runs (`full.yml`, `server.yml`, etc.) use `state: present` +for packages, so they only install what's missing — they never upgrade +anything as a side effect of an unrelated config change. Two separate, +deliberate mechanisms handle upgrades instead: + +**Security patches — automatic.** The `unattended_upgrades` role (applied +to every host in `full.yml`/`server.yml`) configures `unattended-upgrades` +to install security-origin updates automatically, with a scheduled reboot +window (default 03:00, see `roles/unattended_upgrades/defaults/main.yml`) +for patches that need one. Not scoped to full dist-upgrades. + +**Everything else — deliberate, ad hoc.** +``` +just update # e.g. `just update kitchenpi` or `just update` +venv/bin/ansible-playbook update-packages.yml --limit --check --diff # dry run first +``` +Defaults to `upgrade: safe` (upgrades in place, never installs/removes +packages to resolve dependencies — see the comment header in +`update-packages.yml` for the tradeoff against `dist`). There is no cron/ +schedule wired up for this yet; run it ad hoc when you want the fleet +updated, or add a crontab entry yourself (a starting point is documented in +`update-packages.yml`'s header). + +To pin a package to an exact version, or deliberately bump one named +package to latest outside this schedule, copy the pattern in +`update-packages-pinned-example.yml`. diff --git a/full.yml b/full.yml index a362111..ba045e1 100644 --- a/full.yml +++ b/full.yml @@ -11,6 +11,7 @@ - name: Musikserver Wohnzimmer oben hosts: musikserverwohnzimmeroben roles: + - unattended_upgrades - pi_standard_setup - pi_hifiberry_amp - pi_squeezelite_custom @@ -22,6 +23,7 @@ - name: Kitchen pi hosts: kitchenpi roles: + - unattended_upgrades - pi_standard_setup - pi_hifiberry_amp - pi_squeezelite_custom @@ -34,6 +36,7 @@ - name: Bedroom pi hosts: bedroompi roles: + - unattended_upgrades - pi_standard_setup - pi_squeezelite_custom - pi_shairport @@ -45,6 +48,7 @@ - name: Musicmouse hosts: musicmouse roles: + - unattended_upgrades - pi_standard_setup - pi_hifiberry_amp - pi_musicmouse diff --git a/justfile b/justfile index e31db4f..5cf4ec4 100644 --- a/justfile +++ b/justfile @@ -42,6 +42,10 @@ full limit="all": server: just run server.yml server +# Run the deliberate fleet package-update playbook, e.g. `just update kitchenpi` +update limit="all": + just run update-packages.yml {{limit}} + # List installed Galaxy collections collections: {{venv_bin}}/ansible-galaxy collection list diff --git a/roles/pi_standard_setup/tasks/main.yml b/roles/pi_standard_setup/tasks/main.yml index c694f62..159ef08 100644 --- a/roles/pi_standard_setup/tasks/main.yml +++ b/roles/pi_standard_setup/tasks/main.yml @@ -1,7 +1,6 @@ --- -- name: Do apt update/upgrade +- name: Refresh apt cache ansible.builtin.apt: - upgrade: "yes" update_cache: "yes" cache_valid_time: "7200" - name: Detect Raspi Model diff --git a/roles/server_basic_environment/tasks/main.yml b/roles/server_basic_environment/tasks/main.yml index 09f6ac7..dc8379e 100644 --- a/roles/server_basic_environment/tasks/main.yml +++ b/roles/server_basic_environment/tasks/main.yml @@ -1,7 +1,6 @@ --- -- name: Do apt update/upgrade +- name: Refresh apt cache ansible.builtin.apt: - upgrade: "yes" update_cache: "yes" cache_valid_time: "300" - name: Apt install download & unzip packages diff --git a/roles/unattended_upgrades/README.md b/roles/unattended_upgrades/README.md new file mode 100644 index 0000000..6a40bb5 --- /dev/null +++ b/roles/unattended_upgrades/README.md @@ -0,0 +1,9 @@ +# unattended_upgrades + +Installs and configures `unattended-upgrades` for automatic security +patching, separate from `update-packages.yml` (which handles deliberate, +scheduled full-package updates — see the repo README). Security-only by +default. Reboots when required, at a fixed scheduled time (default 03:00), +rather than never or immediately — see `defaults/main.yml` to change this. +Mail-on-failure is supported but disabled by default since no mail +transport is configured on these hosts yet. diff --git a/roles/unattended_upgrades/defaults/main.yml b/roles/unattended_upgrades/defaults/main.yml new file mode 100644 index 0000000..d3f6057 --- /dev/null +++ b/roles/unattended_upgrades/defaults/main.yml @@ -0,0 +1,21 @@ +--- +# Restrict to security-origin updates only (not a full dist-upgrade). +unattended_upgrades_security_only: true + +# Extra Origins-Pattern lines appended verbatim, for when +# unattended_upgrades_security_only is turned off later (e.g. to also allow +# the "-updates" pocket). Left empty by default. +unattended_upgrades_origins_extra: [] + +# Reboot handling. Off by default would mean patches needing a reboot never +# take effect until someone reboots manually; scheduled means an automatic +# reboot at a fixed, low-traffic time on the days it's actually needed. +unattended_upgrades_auto_reboot: true +unattended_upgrades_auto_reboot_time: "03:00" + +unattended_upgrades_remove_unused_deps: true + +# Set to a mail address (and ensure a working mail transport is configured +# on the host) to get notified on failure. Empty disables mail entirely. +unattended_upgrades_mail_to: "" +unattended_upgrades_mail_on_only_error: true diff --git a/roles/unattended_upgrades/tasks/main.yml b/roles/unattended_upgrades/tasks/main.yml new file mode 100644 index 0000000..5d23190 --- /dev/null +++ b/roles/unattended_upgrades/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Install unattended-upgrades + ansible.builtin.apt: + name: unattended-upgrades + state: present + update_cache: true + +- name: Configure unattended-upgrades behavior + ansible.builtin.template: + src: 50unattended-upgrades.j2 + dest: /etc/apt/apt.conf.d/50unattended-upgrades + owner: root + group: root + mode: "0644" + +- name: Enable periodic apt update / unattended-upgrade timers + ansible.builtin.template: + src: 20auto-upgrades.j2 + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + +- name: Ensure apt daily timers are enabled and running + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + loop: + - apt-daily.timer + - apt-daily-upgrade.timer diff --git a/roles/unattended_upgrades/templates/20auto-upgrades.j2 b/roles/unattended_upgrades/templates/20auto-upgrades.j2 new file mode 100644 index 0000000..2536e35 --- /dev/null +++ b/roles/unattended_upgrades/templates/20auto-upgrades.j2 @@ -0,0 +1,7 @@ +// Managed by Ansible (roles/unattended_upgrades) — changes will be overwritten. + +APT::Periodic::Update-Package-Lists "1"; +APT::Periodic::Download-Upgradeable-Packages "1"; +APT::Periodic::AutocleanInterval "7"; +APT::Periodic::Unattended-Upgrade "1"; +APT::Periodic::Verbose "1"; diff --git a/roles/unattended_upgrades/templates/50unattended-upgrades.j2 b/roles/unattended_upgrades/templates/50unattended-upgrades.j2 new file mode 100644 index 0000000..42dcc54 --- /dev/null +++ b/roles/unattended_upgrades/templates/50unattended-upgrades.j2 @@ -0,0 +1,29 @@ +// Managed by Ansible (roles/unattended_upgrades) — changes will be overwritten. + +Unattended-Upgrade::Origins-Pattern { +{% if unattended_upgrades_security_only %} + "origin=${distro_id},codename=${distro_codename},label=${distro_id}-Security"; + "origin=${distro_id},codename=${distro_codename}-security,label=${distro_id}-Security"; + "origin=Debian,codename=${distro_codename},label=Debian-Security"; + "origin=Debian,codename=${distro_codename}-security,label=Debian-Security"; +{% else %} + "origin=${distro_id},codename=${distro_codename}"; + "origin=${distro_id},codename=${distro_codename}-security"; + "origin=${distro_id},codename=${distro_codename}-updates"; +{% endif %} +{% for origin in unattended_upgrades_origins_extra %} + "{{ origin }}"; +{% endfor %} +}; + +Unattended-Upgrade::Remove-Unused-Dependencies "{{ unattended_upgrades_remove_unused_deps | lower }}"; + +Unattended-Upgrade::Automatic-Reboot "{{ unattended_upgrades_auto_reboot | lower }}"; +Unattended-Upgrade::Automatic-Reboot-Time "{{ unattended_upgrades_auto_reboot_time }}"; + +Unattended-Upgrade::SyslogEnable "true"; + +{% if unattended_upgrades_mail_to %} +Unattended-Upgrade::Mail "{{ unattended_upgrades_mail_to }}"; +Unattended-Upgrade::MailOnlyOnError "{{ unattended_upgrades_mail_on_only_error | lower }}"; +{% endif %} diff --git a/server.yml b/server.yml index 46497a8..94547ba 100644 --- a/server.yml +++ b/server.yml @@ -2,6 +2,7 @@ - name: Server hosts: server roles: + - unattended_upgrades - server_basic_environment - server_debian_docker - server_exthdd_mount diff --git a/update-packages-pinned-example.yml b/update-packages-pinned-example.yml new file mode 100644 index 0000000..9bc29c4 --- /dev/null +++ b/update-packages-pinned-example.yml @@ -0,0 +1,29 @@ +--- +# Template, not run on any schedule. Copy this when a package needs a +# controlled bump outside update-packages.yml — e.g. a security fix you +# want before the next scheduled run, or a version you deliberately don't +# want update-packages.yml's `safe` upgrade to move past. +# +# Run explicitly against the hosts that need it: +# venv/bin/ansible-playbook update-packages-pinned-example.yml --limit + +- name: Pin a package to an exact version + hosts: all + tasks: + - name: Install nginx pinned to a specific version + ansible.builtin.apt: + name: "nginx=1.18.0-6.1+deb11u3" + state: present + update_cache: true + # Pinning like this also protects the package from update-packages.yml's + # `safe`/`dist` upgrade: apt won't move a pinned-by-version install + # past the given version on a plain upgrade. + +- name: Bump a single named package to latest, deliberately + hosts: all + tasks: + - name: Upgrade openssl to latest available + ansible.builtin.apt: + name: openssl + state: latest # noqa: package-latest - deliberate, scoped to one named package, run ad hoc + update_cache: true diff --git a/update-packages.yml b/update-packages.yml new file mode 100644 index 0000000..6364495 --- /dev/null +++ b/update-packages.yml @@ -0,0 +1,50 @@ +--- +# Deliberate, fleet-wide package update run. Decoupled on purpose from +# full.yml/server.yml (which use state: present and must stay idempotent/ +# no-op on routine runs) — this is the one place packages actually get +# upgraded. +# +# Ad hoc run: +# just update +# venv/bin/ansible-playbook update-packages.yml --limit +# +# Dry run first: +# venv/bin/ansible-playbook update-packages.yml --limit --check --diff +# +# Scheduling: not automated yet. When you're ready, the simplest option is +# a crontab entry on whichever machine you normally run ansible-playbook +# from, e.g. weekly at 04:00 on Sundays: +# 0 4 * * 0 cd /path/to/this/repo && venv/bin/ansible-playbook update-packages.yml >> update.log 2>&1 +# Adjust the schedule by editing that line (`crontab -e`). Note this only +# fires if that machine is on and networked at the scheduled time. +# +# upgrade type tradeoff (update_packages_upgrade_type, default "safe"): +# safe - upgrades packages in place, never installs/removes packages to +# resolve dependencies. Won't silently swap out a kernel +# meta-package or drop something. Recommended default for an +# unattended/fleet-wide run. +# dist - full upgrade, will install/remove packages as needed (kernel +# transitions, etc). More thorough, more surprising. Use +# deliberately: `-e update_packages_upgrade_type=dist`. +- name: Update packages across the fleet + hosts: all + vars: + update_packages_upgrade_type: safe + update_packages_autoremove: false + tasks: + - name: Update apt cache and upgrade packages + ansible.builtin.apt: + update_cache: true + upgrade: "{{ update_packages_upgrade_type }}" + autoremove: "{{ update_packages_autoremove }}" + register: update_packages_result + + - name: Check whether a reboot is required after this upgrade + ansible.builtin.stat: + path: /var/run/reboot-required + register: update_packages_reboot_required + + - name: Report reboot required + ansible.builtin.debug: + msg: "Reboot required on {{ inventory_hostname }}" + when: update_packages_reboot_required.stat.exists