--- # Enables automatic security updates: unattended-upgrades on Ubuntu and # dnf-automatic on Rocky Linux. Neither reboots automatically; use # reboot_check.yml for that. # Play 1: probe every ubuntu/rocky host and sort them into a "reachable_hosts" # group so the two update plays below can skip anything that's offline. - name: Check Host Availability hosts: ubuntu:rocky gather_facts: false ignore_unreachable: true tasks: # Ping each host; failures/unreachables are swallowed so the play keeps # going instead of aborting the whole run. - name: Ping hosts to check availability ansible.builtin.ping: register: ping_result ignore_errors: true ignore_unreachable: true # Dynamically creates/populates the "reachable_hosts" group, used by the # Ubuntu and Rocky plays' host patterns (ubuntu:&reachable_hosts etc.). - name: Add reachable hosts to group ansible.builtin.group_by: key: reachable_hosts when: ping_result is reachable and ping_result is succeeded # Print a message for any host that failed the ping, so offline hosts # are visible in the run output. - name: Report offline hosts ansible.builtin.debug: msg: "{{ inventory_hostname }} - OFFLINE" when: ping_result is unreachable or ping_result is failed delegate_to: localhost # Play 2: install and configure unattended-upgrades on Ubuntu hosts, then # report its status. Does not enable automatic reboots. - name: Auto Updates - Ubuntu hosts: ubuntu:&reachable_hosts become: true gather_facts: false tasks: # Make sure the unattended-upgrades package is present. - name: Install unattended-upgrades ansible.builtin.apt: name: unattended-upgrades state: present update_cache: true cache_valid_time: 3600 # Turn on apt's periodic package-list refresh, unattended-upgrade run, # and weekly autoclean. - name: Enable periodic package list refresh and upgrades ansible.builtin.copy: dest: /etc/apt/apt.conf.d/20auto-upgrades content: | // Managed by Ansible (playbooks/auto_updates.yml) APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; owner: root group: root mode: '0644' # Local overrides layered on top of the stock 50unattended-upgrades # config: clean up unused deps, but never reboot automatically. - name: Configure unattended-upgrades behaviour ansible.builtin.copy: dest: /etc/apt/apt.conf.d/52unattended-upgrades-local content: | // Managed by Ansible (playbooks/auto_updates.yml) // Allowed origins (security / ESM) come from the stock 50unattended-upgrades. Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; owner: root group: root mode: '0644' # Enable and start the systemd timers that actually trigger apt's # periodic update/upgrade cycle. - name: Enable apt timers ansible.builtin.systemd: name: "{{ item }}" enabled: true state: started loop: - apt-daily.timer - apt-daily-upgrade.timer # Simulate an unattended-upgrade run (no changes made) so the report # below shows what would actually be upgraded. - name: Dry-run unattended-upgrade ansible.builtin.command: unattended-upgrade --dry-run -v register: uu_dry_run changed_when: false failed_when: false # Pull the package list out of the dry-run's "Packages that will be # upgraded: ..." line, used for the per-host and final summary reports. - name: Extract pending package list ansible.builtin.set_fact: pending_update_packages: "{{ (uu_dry_run.stdout | regex_findall('Packages that will be upgraded:\\s*(.*)') | first | default('')).split() }}" - name: Compute pending update count ansible.builtin.set_fact: pending_update_count: "{{ pending_update_packages | length }}" # Grab the current state of the apt-daily timers for the report. - name: List apt timers # noqa: command-instead-of-module (read-only listing) ansible.builtin.command: systemctl list-timers apt-daily* --no-pager --all register: apt_timers changed_when: false # Summarize timer status, pending update count, and the tail of the # dry-run output per host. - name: Print report ansible.builtin.debug: msg: | ====================================================== HOST: {{ inventory_hostname }} ({{ ansible_host }}) ====================================================== TIMERS: {{ apt_timers.stdout }} ------------------------------------------------------ PENDING UPDATES: {{ pending_update_count }} {{ pending_update_packages | join(', ') }} ------------------------------------------------------ DRY RUN (last lines): {{ (uu_dry_run.stdout_lines + uu_dry_run.stderr_lines)[-6:] | join('\n') }} ====================================================== # Play 3: install and configure dnf-automatic on Rocky Linux hosts (security # updates only), then report its status. - name: Auto Updates - Rocky Linux hosts: rocky:&reachable_hosts become: true gather_facts: false tasks: # Make sure the dnf-automatic package is present. - name: Install dnf-automatic ansible.builtin.dnf: name: dnf-automatic state: present # Set dnf-automatic to download and apply security updates only, and # log output to stdout (captured by the systemd journal). - name: Configure dnf-automatic for security updates community.general.ini_file: path: /etc/dnf/automatic.conf section: "{{ item.section }}" option: "{{ item.option }}" value: "{{ item.value }}" mode: '0644' loop: - { section: commands, option: upgrade_type, value: security } - { section: commands, option: download_updates, value: "yes" } - { section: commands, option: apply_updates, value: "yes" } - { section: emitters, option: emit_via, value: stdio } loop_control: label: "{{ item.section }}/{{ item.option }} = {{ item.value }}" # Enable and start the systemd timer that triggers dnf-automatic runs. - name: Enable dnf-automatic timer ansible.builtin.systemd: name: dnf-automatic.timer enabled: true state: started # Grab the current state of the dnf-automatic timer for the report. - name: List dnf-automatic timers # noqa: command-instead-of-module (read-only listing) ansible.builtin.command: systemctl list-timers dnf-automatic* --no-pager --all register: dnf_timers changed_when: false # Check what security updates are pending. rc 100 means updates are # available, rc 0 means none, so only other codes count as a failure. - name: Check for pending security updates ansible.builtin.command: dnf check-update --security register: dnf_check changed_when: false failed_when: dnf_check.rc not in [0, 100] # Pull the "name.arch version repo" lines out of the check-update # output, used for the per-host and final summary reports. - name: Extract pending package list ansible.builtin.set_fact: pending_update_packages: "{{ dnf_check.stdout_lines | select('match', '^\\S+\\.\\S+\\s+\\S+\\s+\\S+$') | map('regex_replace', '^(\\S+)\\s.*$', '\\1') | list }}" - name: Compute pending update count ansible.builtin.set_fact: pending_update_count: "{{ pending_update_packages | length }}" # Summarize timer status and pending update count per host. - name: Print report ansible.builtin.debug: msg: | ====================================================== HOST: {{ inventory_hostname }} ({{ ansible_host }}) ====================================================== TIMERS: {{ dnf_timers.stdout }} ------------------------------------------------------ PENDING UPDATES: {{ pending_update_count }} {{ pending_update_packages | join(', ') }} ====================================================== # Play 4: after both OS-specific plays have run, print one consolidated # summary of which hosts have pending updates and how many packages. - name: Summary - Pending Updates hosts: localhost gather_facts: false tasks: - name: Print hosts with pending updates ansible.builtin.debug: msg: "{{ item }} - {{ hostvars[item].pending_update_count }} package(s) pending: {{ hostvars[item].pending_update_packages | join(', ') }}" loop: "{{ groups['reachable_hosts'] | default([]) }}" when: hostvars[item].pending_update_count | default(0) | int > 0 - name: Print hosts fully up to date ansible.builtin.debug: msg: "{{ item }} - up to date" loop: "{{ groups['reachable_hosts'] | default([]) }}" when: hostvars[item].pending_update_count is defined and hostvars[item].pending_update_count | int == 0 - name: Print totals ansible.builtin.debug: msg: >- {{ groups['reachable_hosts'] | default([]) | length }} host(s) reachable, {{ groups['reachable_hosts'] | default([]) | map('extract', hostvars, 'pending_update_count') | select('defined') | map('int') | select('>', 0) | list | length }} with pending updates, {{ groups['reachable_hosts'] | default([]) | map('extract', hostvars, 'pending_update_count') | select('defined') | map('int') | sum }} package(s) total pending.