--- # 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: 10 delay: 2 - name: Store node facts ansible.builtin.set_fact: firmware_action: "{{ opnsense_action | default('update')}}" firmware_status: "{{ _firmware_status.json.status }}" firmware_current_version: "{{ _firmware_status.json.product.product_version | default('unknown') }}" firmware_product_series: "{{ _firmware_status.json.product.product_series | default('unknown') }}" firmware_update_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') }}" firmware_upgrade_message: "{{ _firmware_status.json.upgrade_major_message | regex_replace('<[^>]+>', ' ') | regex_replace('\\s{2,}', ' ') | trim | default('') }}" update_needs_reboot: "{{ _firmware_status.json.needs_reboot == '1' }}" upgrade_needs_reboot: "{{ _firmware_status.json.upgrade_needs_reboot == '1' }}" 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: Resolve update-vs-upgrade specifics ansible.builtin.set_fact: needs_reboot: "{{ upgrade_needs_reboot if firmware_status == 'upgrade' else update_needs_reboot }}" firmware_target_kind: "{{ 'series' if firmware_status == 'upgrade' else 'version' }}" firmware_target_value: "{{ firmware_upgrade_version if firmware_status == 'upgrade' else firmware_update_version }}" - name: Skip backup {{ firmware_action }} 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' if hostvars[groups['opnsense_master'][0]].firmware_target_kind == 'version' else 'firmware_product_series']) == hostvars[groups['opnsense_master'][0]].firmware_target_value - name: "Assert" ansible.builtin.assert: that: - firmware_action == firmware_status or skip_update | default(false) - not in_maintenance - mismatched_vips == 0 - total_vips > 0 quiet: true 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_action != firmware_status %} {% if firmware_status == "upgrade" %} Upgrade to {{ firmware_upgrade_version }} available, but no upgrade requested. {% if firmware_upgrade_message != "" %} Upgrade message: {{ firmware_upgrade_message }} {% endif %} {% 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 {{ firmware_action }} report" 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 any_errors_fatal: true 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 {{ firmware_action }} ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/{{ firmware_action }}" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Check if the {{ firmware_action }} 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 failed_when: _update_running.json.status != 'busy' - name: Wait for node to reboot after the {{ firmware_action }} 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_' ~ firmware_target_kind] | default('unknown') == firmware_target_value 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/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 ignore_errors: true 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 }} {{ firmware_action | capitalize }} to {{ firmware_update_version }} failed on {{ inventory_hostname }} ({{ main_role }}). No snapshot have been taken before the {{ firmware_action }}. headers: Title: "OPNsense {{ firmware_action }} 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 any_errors_fatal: true 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-{{ firmware_action }}: {{ firmware_current_version }} → {{ firmware_update_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 {{ firmware_action }} ansible.builtin.uri: url: "https://{{ opnsense_host }}/api/core/firmware/{{ firmware_action }}" method: POST user: "{{ opnsense_api_key }}" password: "{{ opnsense_api_secret }}" force_basic_auth: true validate_certs: false - name: Check if the {{ firmware_action }} 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 failed_when: _update_running.json.status != 'busy' - name: Wait for node to reboot after the {{ firmware_action }} 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: 900 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_' ~ firmware_target_kind] | default('unknown') == firmware_target_value 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 ignore_errors: true rescue: - name: Rollback VM to the pre-{{ firmware_action }} 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 }} {{ firmware_action | capitalize }} to {{ firmware_update_version }} failed on {{ inventory_hostname }} ({{ main_role }}). A snapshot have been taken before the {{ firmware_action }} and the VM has been rollback. headers: Title: "OPNsense {{ firmware_action }} 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 {{ firmware_action }} 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_update_version }}{% endif %}{{ "\n" if not loop.last else "" }} {% endfor %} headers: Title: "OPNsense {{ firmware_action }} completed" Priority: "default" Tags: "white_check_mark" when: ntfy_url is defined