diff --git a/roles/sys_autoupdate/README.md b/roles/sys_autoupdate/README.md index 5051fc1..7fadfbd 100644 --- a/roles/sys_autoupdate/README.md +++ b/roles/sys_autoupdate/README.md @@ -27,6 +27,26 @@ sys_autoupdate_ntfy_topic: your-notification-topic OS-specific commands are defined in `vars/archlinux.yml` and `vars/debian.yml`, loaded automatically via `ansible_facts['os_family']`. +## Excluding packages from auto-update + +Set `sys_autoupdate_ignore_packages` to a list of package names to hold back. Useful when running out-of-tree kernel modules (ZFS DKMS, Nvidia, VirtualBox) that must stay in lockstep with the kernel. + +```yaml +# Arch with ZFS DKMS +sys_autoupdate_ignore_packages: + - linux + - linux-headers + - zfs-dkms + - zfs-utils +``` + +Translation per OS: + +- **Arch:** passed as `pacman --ignore=pkg1,pkg2` on each run. Held packages are also filtered out of the pending-update detection. +- **Debian/Ubuntu:** reconciled via `apt-mark hold` at role-apply time. Holds added by this role are not automatically released when removed from the list — run `sudo apt-mark unhold ` to release them manually. + +**Warning:** when holding the kernel, also hold matching headers and any DKMS modules together. Holding the kernel while letting `zfs-dkms` advance can break the DKMS build on next boot. + ## Podman image updates When `sys_autoupdate_podman_enabled: true` (default), the script scans `podman_projects_dir` for `docker-compose.yml` files, pulls images via `podman-compose pull`, and recreates containers with `podman-compose up -d` for projects with updated images. Dangling images are pruned after each run. @@ -41,3 +61,4 @@ The script runs as root (for package management) and uses `sudo -u {{ ansible_us | `x` | Update or pod restart failed | | `warning` | Distro news requires manual review (Arch) | | `whale` | Podman images updated | +| `arrows_counterclockwise` | Running kernel older than installed — reboot pending (notified weekly, `sys_autoupdate_reboot_notify_weekday`) | diff --git a/roles/sys_autoupdate/defaults/main.yml b/roles/sys_autoupdate/defaults/main.yml index 517015c..7ff80c1 100644 --- a/roles/sys_autoupdate/defaults/main.yml +++ b/roles/sys_autoupdate/defaults/main.yml @@ -17,8 +17,34 @@ sys_autoupdate_randomized_delay: 60m sys_autoupdate_check_news: true sys_autoupdate_news_hours: 24 +# Kernel reboot check cadence: notify drift (running vs installed kernel) only +# on this day of the week (1=Monday .. 7=Sunday) to avoid daily nagging. +sys_autoupdate_reboot_notify_weekday: 1 + sys_autoupdate_allow_downgrade: false +# Packages excluded from auto-update. +# Arch: passed as `pacman --ignore=pkg1,pkg2` +# Debian/Ubuntu: reconciled via `apt-mark hold` / `apt-mark unhold` on each run +# +# Common use case: hold kernel back when running out-of-tree modules (ZFS DKMS, +# Nvidia, VirtualBox, etc.). When holding the kernel, also hold matching headers +# and any DKMS modules to keep them in lockstep. +# +# Example (Arch with ZFS DKMS): +# sys_autoupdate_ignore_packages: +# - linux +# - linux-headers +# - zfs-dkms +# - zfs-utils +# +# Example (Debian/Ubuntu with ZFS DKMS): +# sys_autoupdate_ignore_packages: +# - linux-image-generic +# - linux-headers-generic +# - zfs-dkms +sys_autoupdate_ignore_packages: [] + # Podman image auto-update (rootless, runs as ansible_user) # Pulls latest images via podman-compose and recreates containers if changed sys_autoupdate_podman_enabled: true diff --git a/roles/sys_autoupdate/tasks/main.yml b/roles/sys_autoupdate/tasks/main.yml index 392f98f..3a78935 100644 --- a/roles/sys_autoupdate/tasks/main.yml +++ b/roles/sys_autoupdate/tasks/main.yml @@ -19,6 +19,26 @@ name: "{{ sys_autoupdate_packages }}" state: present +- name: Reconcile apt package holds (Debian/Ubuntu) + when: ansible_facts['os_family'] == 'Debian' + block: + - name: Get currently held apt packages + ansible.builtin.command: apt-mark showhold + register: sys_autoupdate_current_holds + changed_when: false + + - name: Hold packages listed in sys_autoupdate_ignore_packages + ansible.builtin.dpkg_selections: + name: "{{ item }}" + selection: hold + loop: "{{ sys_autoupdate_ignore_packages }}" + when: item not in sys_autoupdate_current_holds.stdout_lines + + # NOTE: holds set manually (outside this role) or in previous runs are not + # automatically released when removed from sys_autoupdate_ignore_packages. + # This is intentional: avoids accidentally unholding operator-pinned packages. + # To release a hold, run: sudo apt-mark unhold + - name: Deploy autoupdate script ansible.builtin.template: src: sys-autoupdate.sh.j2 diff --git a/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 b/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 index cc8be68..cbfc29e 100644 --- a/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 +++ b/roles/sys_autoupdate/templates/sys-autoupdate.sh.j2 @@ -13,6 +13,7 @@ PODMAN_PROJECTS_DIR="{{ sys_autoupdate_podman_projects_dir }}" PODMAN_USER="{{ ansible_user }}" PODMAN_PRUNE_UNTIL="{{ sys_autoupdate_podman_prune_until }}" +HOSTNAME="{{ ansible_facts['hostname'] }}" DISTRO_NAME="{{ sys_autoupdate_distro_name }}" OS_KEY="{{ sys_autoupdate_os_key }}" ALLOW_DOWNGRADE="{{ sys_autoupdate_allow_downgrade | lower }}" @@ -20,6 +21,8 @@ HAS_NEWS_CHECK="{{ sys_autoupdate_has_news_check | lower }}" CHECK_NEWS="{{ sys_autoupdate_check_news | lower }}" NEWS_HOURS="{{ sys_autoupdate_news_hours }}" NEWS_URL="{{ sys_autoupdate_news_url }}" +IGNORE_PACKAGES="{{ sys_autoupdate_ignore_packages | join(',') }}" +REBOOT_NOTIFY_WEEKDAY="{{ sys_autoupdate_reboot_notify_weekday }}" {% raw %} log() { @@ -53,10 +56,20 @@ check_pending_updates() { archlinux) # checkupdates (pacman-contrib) exits 2 when no updates are pending; # swallow non-zero so set -e doesn't abort the script. - checkupdates 2>/dev/null || true + # Honor ignored packages by filtering them out of the pending list. + local raw + raw=$(checkupdates 2>/dev/null || true) + if [[ -n "$IGNORE_PACKAGES" && -n "$raw" ]]; then + local ignore_regex + ignore_regex="^($(echo "$IGNORE_PACKAGES" | tr ',' '|')) " + echo "$raw" | grep -Ev "$ignore_regex" || true + else + echo "$raw" + fi ;; debian) apt-get update -qq >/dev/null 2>&1 + # Held packages are automatically excluded from dist-upgrade simulation. apt-get -s dist-upgrade 2>/dev/null | grep '^Inst ' || true ;; *) @@ -69,13 +82,18 @@ check_pending_updates() { apply_upgrade() { case "$OS_KEY" in archlinux) + local -a pacman_args=(-Syu --noconfirm) if [[ "$ALLOW_DOWNGRADE" == "true" ]]; then - pacman -Syu --noconfirm --allow-downgrade - else - pacman -Syu --noconfirm + pacman_args+=(--allow-downgrade) fi + if [[ -n "$IGNORE_PACKAGES" ]]; then + pacman_args+=(--ignore="$IGNORE_PACKAGES") + fi + pacman "${pacman_args[@]}" ;; debian) + # Holds are reconciled by Ansible (apt-mark hold/unhold) before each + # run. dist-upgrade automatically skips held packages. local -a apt_args=(dist-upgrade -y) if [[ "$ALLOW_DOWNGRADE" == "true" ]]; then apt_args+=(--allow-downgrades) @@ -137,7 +155,7 @@ check_distro_news() { log "Recent $DISTRO_NAME news detected (${#recent_titles[@]} items within ${hours}h):" log "$news_list" send_notification \ - "$(hostname): Manual Intervention Required" \ + "$HOSTNAME: Manual Intervention Required" \ "Recent $DISTRO_NAME news (last ${hours}h). Review before updating.\n\n${news_list}\n\n$NEWS_URL" \ "warning" \ "high" @@ -165,14 +183,14 @@ update_system() { if apply_upgrade 2>&1; then log "System updates completed successfully ($update_count packages)" send_notification \ - "$(hostname): System Update Success" \ + "$HOSTNAME: System Update Success" \ "$update_count packages updated" \ "white_check_mark" \ "default" else log "ERROR: System update failed" send_notification \ - "$(hostname): System Update FAILED" \ + "$HOSTNAME: System Update FAILED" \ "Update failed. Check: journalctl -u sys-autoupdate.service" \ "x" \ "urgent" @@ -180,6 +198,45 @@ update_system() { fi } +# Detect drift between the running kernel and the newest installed one. +# Fires a notification when a reboot is pending, so a long-lived host isn't +# silently left on a stale kernel. +check_kernel_reboot() { + # Only notify once a week (1=Mon .. 7=Sun) to avoid daily nagging. + if [[ "$(date +%u)" != "$REBOOT_NOTIFY_WEEKDAY" ]]; then + return 0 + fi + + local detail="" + + case "$OS_KEY" in + archlinux) + local running latest + running=$(uname -r) + latest=$(ls /usr/lib/modules/ 2>/dev/null | sort -V | tail -1 || echo "") + if [[ -n "$latest" && "$running" != "$latest" ]]; then + detail="Running kernel $running is older than installed $latest." + fi + ;; + debian) + if [[ -f /var/run/reboot-required ]]; then + detail=$(cat /var/run/reboot-required) + fi + ;; + esac + + if [[ -n "$detail" ]]; then + log "Kernel drift detected: $detail" + send_notification \ + "$HOSTNAME: Reboot Required" \ + "${detail}\n\nA reboot is needed to load the new kernel." \ + "arrows_counterclockwise" \ + "high" + else + log "Running kernel is up to date (no reboot pending)" + fi +} + # Run a command as the rootless Podman user with a proper login environment. # `runuser -l` invokes a login shell, so XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, # PATH, and HOME are set correctly for rootless Podman state under @@ -223,7 +280,7 @@ update_podman_images() { local pull_tail pull_tail=$(echo "$pull_output" | tail -n 5) send_notification \ - "$(hostname): Podman Pull Failed ($project_name)" \ + "$HOSTNAME: Podman Pull Failed ($project_name)" \ "Failed to pull images.\n\nLast output:\n$pull_tail\n\nFull log: journalctl -u sys-autoupdate.service" \ "x" \ "high" @@ -260,7 +317,7 @@ update_podman_images() { up_tail=$(echo "$up_output" | tail -n 5) send_notification \ - "$(hostname): Podman Restart FAILED ($project_name)" \ + "$HOSTNAME: Podman Restart FAILED ($project_name)" \ "up -d exit=$up_status, $running_count container(s) running.\n\nLast output:\n$up_tail\n\nFull log: journalctl -u sys-autoupdate.service" \ "x" \ "urgent" @@ -283,7 +340,7 @@ update_podman_images() { service_list=$(printf '%s, ' "${updated_services[@]}") service_list="${service_list%, }" send_notification \ - "$(hostname): Podman Images Updated" \ + "$HOSTNAME: Podman Images Updated" \ "${#updated_services[@]} project(s) updated: $service_list" \ "whale" \ "default" @@ -309,6 +366,8 @@ main() { update_podman_images fi + check_kernel_reboot + log "=== Auto-update run complete ===" }