--- # OPNsense HA Cluster Update # Order: backup (TrueNAS) first, then master (Proxmox) ############################################################### - name: "Phase 1 | Firmware check on all nodes" hosts: opnsense any_errors_fatal: true gather_facts: false tasks: - block: - name: Check node availability ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/system/status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Trigger firmware check ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/check" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Check VIP status ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/get_vip_status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _vip_status - name: Wait for check to complete ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _firmware_status until: _firmware_status.json.status != 'none' retries: 12 delay: 5 - name: Store firmware facts ansible.builtin.set_fact: firmware_status: "{{ _firmware_status.json.status }}" firmware_current_version: "{{ _firmware_status.json.product_version | default('unknown') }}" firmware_new_version: "{{ _firmware_status.json.upgrade_packages | selectattr('name', 'equalto', 'opnsense') | map(attribute='new_version') | first | default('') }}" firmware_upgrade_version: "{{ _firmware_status.json.upgrade_major_version | default('unknown') }}" needs_reboot: "{{ true if _firmware_status.json.needs_reboot == '1' else false }}" total_vips: "{{ _vip_status.json.rowCount }}" mismatched_vips: "{{ _vip_status.json.rows | rejectattr('status', 'equalto', main_role) | list | length }}" in_maintenance: "{{ _vip_status.json.carp.maintenancemode }}" - name: Skip backup update ansible.builtin.set_fact: skip_update: true firmware_status: "skipped" delegate_to: "{{ groups['opnsense_backup'][0] }}" delegate_facts: true run_once: true when: hostvars[groups['opnsense_backup'][0]].firmware_current_version == hostvars[groups['opnsense_master'][0]].firmware_new_version - name: "Assert" ansible.builtin.assert: that: - firmware_status == 'update' or skip_update - not in_maintenance - mismatched_vips == 0 - total_vips > 0 quiet: true - name: Display firmware status ansible.builtin.debug: msg: >- {{ inventory_hostname }} ({{ main_role }}): {{ firmware_status }} ({{ firmware_current_version }} {% if firmware_new_version %} → {{ firmware_new_version }} (reboot: {{ needs_reboot }}){% endif %}) rescue: - name: Send Ntfy notification ansible.builtin.uri: url: "{{ ntfy_url }}/{{ ntfy_topic }}" method: POST user: "{{ ntfy_user }}" password: "{{ lookup('env', 'NTFY_PASSWORD') }}" force_basic_auth: true body: | Current version: {{ firmware_current_version }} {% if firmware_status != "update" %} {% if firmware_status == "upgrade" %} Upgrade to {{ firmware_upgrade_version }} available, relaunch the playbook manually to proceed. {% else %} No updates available on the cluster. {% endif %} {% elif in_maintenance %} The node {{ inventory_hostname }} is in CARP maintenance mode. {% elif mismatched_vips > 0 %} Some VIP are not {{ main_role }} on {{ ansible_host }}. {% else %} No VIPs are managed. {% endif %} headers: Title: "OPNsense update aborted" Priority: "default" run_once: true when: ntfy_url is defined - ansible.builtin.fail: msg: "Update aborted" - name: "Phase 2 | Update backup node (TrueNAS, no snapshot)" hosts: opnsense_backup gather_facts: false tasks: - when: not skip_update | default(false) block: - name: Enable CARP maintenance mode ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/carp_status/maintenance" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _enable_maintenance changed_when: _enable_maintenance.status == "ok" - name: Trigger firmware update ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/update" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Wait for packages to update ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/running" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _update_running until: _update_running.json.status != 'busy' retries: 300 delay: 15 - name: Wait for node to go offline ansible.builtin.wait_for: host: "{{ ansible_host }}" port: "{{ opnsense_https_port }}" state: stopped timeout: 60 when: needs_reboot - name: Wait for node to come back online ansible.builtin.wait_for: host: "{{ ansible_host }}" port: "{{ opnsense_https_port }}" state: started timeout: 300 delay: 30 when: needs_reboot - name: Wait for API to be fully responsive ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _post_status until: _post_status.status == 200 retries: 20 delay: 10 - name: Check VIP status ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/get_vip_status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _carp_post_udapte - name: Disable CARP maintenance mode ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/carp/status/maintenancemode" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false when: _carp_post_udapte.json.carp.maintenancemode rescue: - name: Send Ntfy notification ansible.builtin.uri: url: "{{ ntfy_url }}/{{ ntfy_topic }}" method: POST user: "{{ ntfy_user }}" password: "{{ lookup('env', 'NTFY_PASSWORD') }}" force_basic_auth: true body: | Current version: {{ firmware_current_version }} Update to {{ firmware_new_version }} failed on {{ inventory_hostname }} ({{ main_role }}). No snapshot have been taken before the update. headers: Title: "OPNsense update failed on {{ inventory_hostname }}" Priority: "high" Tags: "x" run_once: true when: ntfy_url is defined - ansible.builtin.fail: msg: "Update failed" - name: "Phase 3 | Update master node (Proxmox, with snapshot)" hosts: opnsense_master gather_facts: false vars: proxmox_snap_name: "preupdate-{{ now().strftime('%Y%m%d') }}" tasks: - block: - name: Take Proxmox VM snapshot community.proxmox.proxmox_snap: vmid: "{{ proxmox_vmid }}" state: present snapname: "{{ proxmox_snap_name }}" description: "Pre-firmware-update: {{ firmware_current_version }} → {{ firmware_new_version }}" - name: Enable CARP maintenance mode ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/carp_status/maintenance" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _enable_maintenance changed_when: _enable_maintenance.status == "ok" - name: Wait for CARP failover to settle ansible.builtin.pause: seconds: 5 - name: Trigger firmware update ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/update" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Check if the update is running ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/running" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _update_running failedd_when: _update_running.json.status != 'busy' - name: Wait for node to reboot after the update ansible.builtin.wait_for: host: "{{ ansible_host }}" port: "{{ opnsense_https_port }}" state: stopped timeout: 3600 when: needs_reboot - name: Wait for node to come back online ansible.builtin.wait_for: host: "{{ ansible_host }}" port: "{{ opnsense_https_port }}" state: started timeout: 300 delay: 30 when: needs_reboot - name: Check firmware version ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/status" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _post_firmware_status until: _post_firmware_status.json.product_version | default('unknown') == firmware_new_version retries: 240 delay: 15 - name: Check VIP status ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/get_vip_status" method: GET user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false register: _carp_post_udapte - name: Disable CARP maintenance mode ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/diagnostics/interface/carp_status/maintenance" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false when: _carp_post_udapte.json.carp.maintenancemode rescue: - name: Rollback VM to the pre-update snapshot community.proxmox.proxmox_snap: vmid: "{{ proxmox_vmid }}" state: rollback snapname: "{{ proxmox_snap_name }}" register: _proxmox_snapshot_rollback ignore_errors: true - name: Send Ntfy notification ansible.builtin.uri: url: "{{ ntfy_url }}/{{ ntfy_topic }}" method: POST user: "{{ ntfy_user }}" password: "{{ lookup('env', 'NTFY_PASSWORD') }}" force_basic_auth: true body: | Current version: {{ firmware_current_version }} Update to {{ firmware_new_version }} failed on {{ inventory_hostname }} ({{ main_role }}). # {% if } A snapshot have been taken before the update and the VM has been rollback. headers: Title: "OPNsense update failed on {{ inventory_hostname }}" Priority: "high" Tags: "x" run_once: true when: ntfy_url is defined - ansible.builtin.fail: msg: "Update aborted" - name: "Phase 4 | Notify on succes" hosts: localhost gather_facts: false tasks: - name: Send Ntfy notification vars: master: "{{ groups['opnsense_master'][0] }}" backup: "{{ groups['opnsense_backup'][0] }}" ansible.builtin.uri: url: "{{ ntfy_url }}/{{ ntfy_topic }}" method: POST user: "{{ ntfy_user }}" password: "{{ lookup('env', 'NTFY_PASSWORD') }}" force_basic_auth: true body: | OPNsense HA cluster update completed successfully. {% for host in [master, backup] %} {% set h = hostvars[host] %} {{ h.main_role }} ({{ host }}): {% if h.skip_update %}already on {{ h.firmware_current_version }}, skipped{% else %}{{ h.firmware_current_version }} → {{ h.firmware_new_version }}{% endif %}{{ "\n" if not loop.last else "" }} {% endfor %} headers: Title: "OPNsense update completed" Priority: "default" Tags: "white_check_mark" when: ntfy_url is defined