From fbac01eea94f13d15dba0ee8291c7af1ed4ba82a Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Sat, 18 Jul 2026 00:07:49 +0200 Subject: [PATCH] Refactor os-updates: deb822 support, codename alignment, logging, reboot task, drop major-version upgrade; add healthcheck to update playbook --- playbooks/os-updates-deb.yml | 12 ++- roles/os-updates/defaults/main.yml | 14 +++- roles/os-updates/handlers/main.yml | 9 +- roles/os-updates/tasks/logging_postflight.yml | 46 ++++++++++ roles/os-updates/tasks/logging_preflight.yml | 48 +++++++++++ roles/os-updates/tasks/main.yml | 5 -- roles/os-updates/tasks/reboot.yml | 83 +++++++++++++++++++ .../os-updates/tasks/update_major_version.yml | 44 ---------- roles/os-updates/tasks/update_mirrors.yml | 54 +++++++++++- roles/os-updates/tasks/upgrade_packages.yml | 30 +++---- .../templates/sources.list-deb822.j2 | 26 ++++++ roles/os-updates/templates/sources.list.j2 | 24 ++---- 12 files changed, 293 insertions(+), 102 deletions(-) create mode 100644 roles/os-updates/tasks/logging_postflight.yml create mode 100644 roles/os-updates/tasks/logging_preflight.yml create mode 100644 roles/os-updates/tasks/reboot.yml delete mode 100644 roles/os-updates/tasks/update_major_version.yml create mode 100644 roles/os-updates/templates/sources.list-deb822.j2 diff --git a/playbooks/os-updates-deb.yml b/playbooks/os-updates-deb.yml index c3f6ace..9513773 100644 --- a/playbooks/os-updates-deb.yml +++ b/playbooks/os-updates-deb.yml @@ -1,17 +1,23 @@ - hosts: all + user: admin become: true tasks: - name: Verify if system is Debian debug: msg: "This playbook is running on a Debian system." - when: ansible_os_family == "Debian" + when: ansible_facts['os_family'] == "Debian" - name: Stop playbook if system is not Debian fail: msg: "This playbook only supports Debian." - when: ansible_os_family != "Debian" + when: ansible_facts['os_family'] != "Debian" - name: Include OS update role include_role: name: os-updates - when: ansible_os_family == "Debian" \ No newline at end of file + when: ansible_facts['os_family'] == "Debian" + + - name: Include healthcheck role (post-update quality gate) + include_role: + name: healthcheck + when: ansible_facts['os_family'] == "Debian" \ No newline at end of file diff --git a/roles/os-updates/defaults/main.yml b/roles/os-updates/defaults/main.yml index e697faa..661b524 100644 --- a/roles/os-updates/defaults/main.yml +++ b/roles/os-updates/defaults/main.yml @@ -1,9 +1,19 @@ # Standardwerte, die überschrieben werden können os_update_auto_upgrade: true os_also_update_mirror: false + +# Logging of OS update runs (preflight package count/list, updated packages, duration) +os_update_logging_enabled: true +os_update_log_dir: /ansible/logs +os_update_log_inventory: "{{ inventory_file | basename | splitext | first }}" +os_update_log_file: "{{ os_update_log_dir }}/{{ os_update_log_inventory }}/{{ ansible_facts['hostname'] }}/update.log" os_update_mirrors: # Role needs two mirros to use for the sources.list.j2 Template - "http://deb.debian.org/debian" # Enter a main mirror here (not security) - "http://security.debian.org/debian-security" # Enter a security mirror here -os_update_major_version: false # Can either be true or false | To toggle if systems need to be upgraded to newer codename -os_update_version_codename: "{{ ansible_distribution_release }}" # KEEP UNTOUCHED!! | Used for jinja2 Template fill in as it determines the current codename of system where ansible is run on \ No newline at end of file +os_update_version_codename: "{{ ansible_facts['distribution_release'] }}" # KEEP UNTOUCHED!! | Used for jinja2 Template fill in as it determines the current codename of system where ansible is run on +os_update_debian_codenames: + # Only these suites are considered Debian codenames and will be rewritten in sources.list.d + - trixie + - bookworm + - bullseye \ No newline at end of file diff --git a/roles/os-updates/handlers/main.yml b/roles/os-updates/handlers/main.yml index 8295fe2..1210540 100644 --- a/roles/os-updates/handlers/main.yml +++ b/roles/os-updates/handlers/main.yml @@ -3,9 +3,6 @@ clean: yes autoclean: yes -- name: Reboot system - command: /sbin/reboot - async: 1 - poll: 0 - ignore_errors: true - when: reboot_required.stdout == "yes" \ No newline at end of file +- name: apt autoremove + apt: + autoremove: yes \ No newline at end of file diff --git a/roles/os-updates/tasks/logging_postflight.yml b/roles/os-updates/tasks/logging_postflight.yml new file mode 100644 index 0000000..853f3c2 --- /dev/null +++ b/roles/os-updates/tasks/logging_postflight.yml @@ -0,0 +1,46 @@ +# Postflight logging: which packages were updated and how long it took. +# Run logging steps only when os_update_logging_enabled is true. + +- name: Logging - Record end epoch + command: date +%s + register: os_update_log_end_ts + changed_when: false + when: os_update_logging_enabled | bool + +- name: Logging - Compute upgrade duration + set_fact: + os_update_duration_seconds: "{{ (os_update_log_end_ts.stdout | int) - (os_update_log_start_ts.stdout | int) }}" + when: os_update_logging_enabled | bool + +- name: Logging - Ensure log directory exists + file: + path: "{{ os_update_log_file | dirname }}" + state: directory + delegate_to: localhost + when: os_update_logging_enabled | bool + +- name: Logging - Write update log entry + blockinfile: + path: "{{ os_update_log_file }}" + create: yes + marker: "# {mark} ANSIBLE-OS-UPDATE" + block: | + host: {{ ansible_facts['hostname'] }} + started: {{ os_update_log_start_iso.stdout }} + duration_seconds: {{ os_update_duration_seconds }} + upgradable_packages_count: {{ os_update_upgradable_count }} + upgrade_changed: {{ os_update_upgrade_result.changed | default(false) }} + upgrade_failed: {{ os_update_upgrade_result.failed | default(false) }} + upgradable_packages: + {% for ver in os_update_upgradable_versions %} + - {{ ver.split()[0] }} ({{ ver.split()[1] }} -> {{ ver.split()[2] }}) + {% endfor %} + reboot_required: {{ os_update_reboot_required }} + reboot_reason: {{ os_update_reboot_reason }} + reboot_triggered: {{ os_update_reboot_triggered }} + reboot_started: {{ os_update_reboot_start_iso }} + reboot_downtime_seconds: {{ os_update_reboot_downtime_seconds }} + kernel_before: {{ os_update_running_kernel.stdout | default('n/a') }} + kernel_after: {{ os_update_running_kernel_after }} + delegate_to: localhost + when: os_update_logging_enabled | bool \ No newline at end of file diff --git a/roles/os-updates/tasks/logging_preflight.yml b/roles/os-updates/tasks/logging_preflight.yml new file mode 100644 index 0000000..d1339dc --- /dev/null +++ b/roles/os-updates/tasks/logging_preflight.yml @@ -0,0 +1,48 @@ +# Preflight logging: how many and which packages need to be updated. +# Run logging steps only when os_update_logging_enabled is true. + +- name: Logging - Refresh apt cache for accurate preflight + apt: + update_cache: yes + changed_when: false + when: os_update_logging_enabled | bool + +- name: Logging - Record start epoch + command: date +%s + register: os_update_log_start_ts + changed_when: false + when: os_update_logging_enabled | bool + +- name: Logging - Record start ISO time + command: date -Iseconds + register: os_update_log_start_iso + changed_when: false + when: os_update_logging_enabled | bool + +- name: Preflight - Gather upgradable packages + command: apt list --upgradable + register: os_update_upgradable + changed_when: false + when: os_update_logging_enabled | bool + +- name: Preflight - Build list of upgradable package names, versions and count + set_fact: + os_update_upgradable_names: >- + {{ + os_update_upgradable.stdout_lines + | select('search', '\[upgradable') + | map('regex_replace', '^([^/]+)/.*$', '\1') + | list + }} + os_update_upgradable_versions: >- + {{ + os_update_upgradable.stdout_lines + | select('search', '\[upgradable') + | map('regex_replace', '^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from: (\S+)\]$', '\1 \3 \2') + | list + }} + os_update_upgradable_count: >- + {{ + (os_update_upgradable.stdout_lines | select('search', '\[upgradable') | list | length) | int + }} + when: os_update_logging_enabled | bool \ No newline at end of file diff --git a/roles/os-updates/tasks/main.yml b/roles/os-updates/tasks/main.yml index 0554922..4bfc678 100644 --- a/roles/os-updates/tasks/main.yml +++ b/roles/os-updates/tasks/main.yml @@ -3,10 +3,5 @@ include_tasks: update_mirrors.yml ignore_errors: true -- name: Upgrade to new major version if enabled - when: os_update_major_version - include_tasks: update_major_version.yml - ignore_errors: true - - name: Upgrade all packages include_tasks: upgrade_packages.yml \ No newline at end of file diff --git a/roles/os-updates/tasks/reboot.yml b/roles/os-updates/tasks/reboot.yml new file mode 100644 index 0000000..ca65573 --- /dev/null +++ b/roles/os-updates/tasks/reboot.yml @@ -0,0 +1,83 @@ +# Reboot decision and execution. +# Determines whether a reboot is needed because a new kernel was installed, +# performs the reboot asynchronously, waits for the host to come back, and +# records timing facts for the update log. + +- name: Gather running kernel + command: uname -r + register: os_update_running_kernel + changed_when: false + when: ansible_facts['virtualization_type'] != 'lxc' + +- name: Gather latest installed kernel + shell: | + dpkg -l | grep -E '^ii' | grep 'linux-image-[0-9]' | awk '{print $2}' | sort | tail -n 1 + register: os_update_latest_kernel + changed_when: false + failed_when: false + when: ansible_facts['virtualization_type'] != 'lxc' + +- name: Determine reboot decision + set_fact: + os_update_reboot_required: true + os_update_reboot_reason: "new kernel installed ({{ os_update_running_kernel.stdout }} -> {{ os_update_latest_kernel.stdout | regex_replace('^linux-image-', '') }})" + when: + - ansible_facts['virtualization_type'] != 'lxc' + - os_update_latest_kernel.stdout | length > 0 + - os_update_running_kernel.stdout not in os_update_latest_kernel.stdout + +- name: Default reboot decision to false + set_fact: + os_update_reboot_required: "{{ os_update_reboot_required | default(false) }}" + os_update_reboot_reason: "{{ os_update_reboot_reason | default('none') }}" + +- name: Default reboot timing facts + set_fact: + os_update_reboot_triggered: false + os_update_reboot_start_iso: "n/a" + os_update_reboot_downtime_seconds: 0 + os_update_running_kernel_after: "{{ os_update_running_kernel.stdout | default('n/a') }}" + +- block: + - name: Reboot - Record start time + command: date -Iseconds + register: os_update_reboot_start_iso_raw + changed_when: false + + - name: Reboot - Record start epoch + command: date +%s + register: os_update_reboot_start_ts + changed_when: false + + - name: Reboot - Reboot system + command: /sbin/reboot + async: 1 + poll: 0 + ignore_errors: true + + - name: Reboot - Wait for host to come back online + wait_for_connection: + delay: 10 + timeout: 600 + register: os_update_reboot_wait + + - name: Reboot - Record end epoch + command: date +%s + register: os_update_reboot_end_ts + changed_when: false + + - name: Reboot - Compute downtime + set_fact: + os_update_reboot_downtime_seconds: "{{ (os_update_reboot_end_ts.stdout | int) - (os_update_reboot_start_ts.stdout | int) }}" + + - name: Reboot - Gather running kernel after reboot + command: uname -r + register: os_update_running_kernel_after_raw + changed_when: false + + - name: Reboot - Record reboot facts + set_fact: + os_update_reboot_triggered: true + os_update_reboot_start_iso: "{{ os_update_reboot_start_iso_raw.stdout }}" + os_update_running_kernel_after: "{{ os_update_running_kernel_after_raw.stdout }}" + when: os_update_reboot_required | bool \ No newline at end of file diff --git a/roles/os-updates/tasks/update_major_version.yml b/roles/os-updates/tasks/update_major_version.yml deleted file mode 100644 index 03343e8..0000000 --- a/roles/os-updates/tasks/update_major_version.yml +++ /dev/null @@ -1,44 +0,0 @@ -- name: Backup existing sources in /etc/apt - copy: - src: "{{ item }}" - dest: "{{ item }}.bak" - remote_src: yes - loop: "{{ lookup('ansible.builtin.fileglob', '/etc/apt/sources.list.d/*.list') + ['/etc/apt/sources.list'] }}" - when: item | file - -- name: Update sources.list for new major version - template: - src: sources.list.j2 - dest: /etc/apt/sources.list - vars: - os_update_version_codename: "{{ new_version_codename }}" # Variable gets passed by main.yml task - -- name: Update additional repositories in /etc/apt/sources.list.d (deb822 format) - replace: - path: "{{ item }}" - regexp: '^(Suites:.*\s)({{ os_update_version_codename }})(\s|$)' - replace: '\1{{ new_version_codename }}\3' - loop: "{{ lookup('ansible.builtin.fileglob', '/etc/apt/sources.list.d/*.sources') }}" - when: item | file - ignore_errors: true - -- name: Update additional repositories in /etc/apt/sources.list.d (old format fallback) - lineinfile: - path: "{{ item }}" - regexp: '^(deb .* )({{ os_update_version_codename }})' - replace: '\1{{ new_version_codename }}' - loop: "{{ lookup('ansible.builtin.fileglob', '/etc/apt/sources.list.d/*.list') }}" - when: item | file - ignore_errors: true - -- name: Update apt cache - apt: - update_cache: yes - -- name: Perform distribution upgrade - apt: - upgrade: yes - allow_unauthenticated: yes - notify: - - Reboot system - - apt cleanup \ No newline at end of file diff --git a/roles/os-updates/tasks/update_mirrors.yml b/roles/os-updates/tasks/update_mirrors.yml index bdedd13..bf57937 100644 --- a/roles/os-updates/tasks/update_mirrors.yml +++ b/roles/os-updates/tasks/update_mirrors.yml @@ -1,20 +1,66 @@ +- name: Run last dist upgrade before changing codename + apt: + update_cache: yes + upgrade: dist + when: ansible_facts['distribution_release'] != os_update_version_codename + - name: Backup existing sources.list copy: src: /etc/apt/sources.list dest: /etc/apt/sources.list.bak remote_src: yes force: yes + ignore_errors: true + when: ansible_facts['distribution_version'] is version('13', '<=') # Only apply under Debian 13 and newer -- name: Remove existing debian.sources file from LXC image +- name: Remove existing sources.list to avoid conflicts with new deb822 format file: - path: /etc/apt/sources.list.d/debian.sources + path: /etc/apt/sources.list state: absent + when: ansible_facts['distribution_version'] is version('13', '>=') # Only apply for Debian 13 and newer -- name: Update sources.list with new mirrors +- name: Update sources.list.d with new mirrors + template: + src: sources.list-deb822.j2 + dest: /etc/apt/sources.list.d/debian.sources + when: ansible_facts['distribution_version'] is version('13', '>=') # Only apply for Debian 13 and newer + register: cache_update_needed + +- name: Update sources.list with new mirrors for older Debian versions template: src: sources.list.j2 dest: /etc/apt/sources.list + when: ansible_facts['distribution_version'] is version('13', '<') # Only apply for Debian versions older than 13 + register: cache_update_needed + +- name: Find sources list fragments + find: + paths: /etc/apt/sources.list.d + patterns: "*.list,*.sources" + file_type: file + register: apt_sources_list_fragments + +- name: Align suite codenames in sources.list.d for .list files + replace: + path: "{{ item.path }}" + backup: yes + regexp: '^(deb(?:-src)?\s+(?:\[[^\]]+\]\s+)?\S+\s+)({{ os_update_debian_codenames | join("|") }})(-[^\s]+)?(\s+.+)$' + replace: '\1{{ os_update_version_codename }}\3\4' + loop: "{{ apt_sources_list_fragments.files }}" + when: (item.path | regex_search('\.list$')) is not none + register: cache_update_needed + +- name: Align suite codenames in sources.list.d for .sources files + replace: + path: "{{ item.path }}" + backup: yes + regexp: '^(Suites:\s+)({{ os_update_debian_codenames | join("|") }})(-[^\s]+)?(.*)$' + replace: '\1{{ os_update_version_codename }}\3\4' + loop: "{{ apt_sources_list_fragments.files }}" + when: (item.path | regex_search('\.sources$')) is not none + register: cache_update_needed - name: Update apt cache apt: - update_cache: yes \ No newline at end of file + update_cache: yes + when: cache_update_needed is changed \ No newline at end of file diff --git a/roles/os-updates/tasks/upgrade_packages.yml b/roles/os-updates/tasks/upgrade_packages.yml index 67516cf..2e2c18f 100644 --- a/roles/os-updates/tasks/upgrade_packages.yml +++ b/roles/os-updates/tasks/upgrade_packages.yml @@ -1,27 +1,17 @@ +- name: Run preflight logging + include_tasks: logging_preflight.yml + - name: Upgrade all installed packages apt: upgrade: full update_cache: yes - notify: + register: os_update_upgrade_result + notify: - apt cleanup + - apt autoremove -- name: Check if a kernel update is available - shell: | - dpkg -l | grep -E '^ii' | grep 'linux-image-[0-9]' | awk '{print $2}' | sort | tail -n 1 - register: latest_kernel - when: ansible_virtualization_type != 'lxc' +- name: Run reboot decision and reboot if required + include_tasks: reboot.yml -- name: Check if running kernel matches the latest installed kernel - shell: | - echo "{{ latest_kernel.stdout }}" | grep -c $(uname -r) - register: kernel_match - changed_when: false - ignore_errors: true - when: ansible_virtualization_type != 'lxc' - -- name: Mark reboot required if a new kernel is installed - set_fact: - reboot_required: "yes" - when: - - ansible_virtualization_type != 'lxc' - - kernel_match.stdout == "0" \ No newline at end of file +- name: Run postflight logging + include_tasks: logging_postflight.yml \ No newline at end of file diff --git a/roles/os-updates/templates/sources.list-deb822.j2 b/roles/os-updates/templates/sources.list-deb822.j2 new file mode 100644 index 0000000..a2c3b73 --- /dev/null +++ b/roles/os-updates/templates/sources.list-deb822.j2 @@ -0,0 +1,26 @@ +# {{ ansible_managed }} +# Package sources for Debian {{ os_update_version_codename }}. This file is generated by Ansible using the os-updates role. +# Using deb822 format for sources.list as it is the new standard in Debian 13 and newer. This file is placed in /etc/apt/sources.list.d/debian.sources to avoid conflicts with older sources.list files and to allow coexistence during transition. +Types: deb +URIs: {{ os_update_mirrors[0] }} +Suites: {{ os_update_version_codename }} +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + +Types: deb +URIs: {{ os_update_mirrors[0] }} +Suites: {{ os_update_version_codename }}-updates +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + +Types: deb +URIs: {{ os_update_mirrors[0] }} +Suites: {{ os_update_version_codename }}-backports +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg + +Types: deb +URIs: {{ os_update_mirrors[1] }} +Suites: {{ os_update_version_codename }}-security +Components: main contrib non-free non-free-firmware +Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg \ No newline at end of file diff --git a/roles/os-updates/templates/sources.list.j2 b/roles/os-updates/templates/sources.list.j2 index 4db3da0..ed89802 100644 --- a/roles/os-updates/templates/sources.list.j2 +++ b/roles/os-updates/templates/sources.list.j2 @@ -1,20 +1,8 @@ -# {{ ansible_managed }} -Types: deb -URIs: {{ os_update_mirrors[0] }} -Suites: {{ os_update_version_codename }} -Components: main contrib non-free non-free-firmware +# {{ ansible_managed }} | This file is managed by Ansible. Do not edit it manually. +# Package sources for Debian {{ os_update_version_codename }}. This file is generated by Ansible using the os-updates role. -Types: deb -URIs: {{ os_update_mirrors[0] }} -Suites: {{ os_update_version_codename }}-updates -Components: main contrib non-free non-free-firmware +deb {{ os_update_mirrors[0] }} {{ os_update_version_codename }} main contrib non-free non-free-firmware +deb {{ os_update_mirrors[0] }} {{ os_update_version_codename }}-updates main contrib non-free non-free-firmware +deb {{ os_update_mirrors[0] }} {{ os_update_version_codename }}-backports main contrib non-free non-free-firmware -Types: deb -URIs: {{ os_update_mirrors[0] }} -Suites: {{ os_update_version_codename }}-backports -Components: main contrib non-free non-free-firmware - -Types: deb -URIs: {{ os_update_mirrors[1] }} -Suites: {{ os_update_version_codename }}-security -Components: main contrib non-free non-free-firmware \ No newline at end of file +deb {{ os_update_mirrors[1] }} {{ os_update_version_codename }}-security main contrib non-free non-free-firmware \ No newline at end of file