From 5779ee4f9421e00e438f124a3aaba6f040d65ced Mon Sep 17 00:00:00 2001 From: "Ben D." Date: Wed, 29 Apr 2026 10:04:03 -0700 Subject: [PATCH] Split out carp roles --- roles/pfsense_upgrade/tasks/carp_pre.yml | 155 +++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 roles/pfsense_upgrade/tasks/carp_pre.yml diff --git a/roles/pfsense_upgrade/tasks/carp_pre.yml b/roles/pfsense_upgrade/tasks/carp_pre.yml new file mode 100644 index 0000000..7e12dc5 --- /dev/null +++ b/roles/pfsense_upgrade/tasks/carp_pre.yml @@ -0,0 +1,155 @@ +# roles/pfsense_upgrade/tasks/carp_pre.yml +# Handles CARP/HA pre-upgrade logic: verification and demotion. +# Only runs when ha_peer is defined and perform_upgrade is true. +# +# ha_role: backup → minimal pre-checks, upgrade proceeds normally +# ha_role: primary → full CARP state verification, forced demotion + +# --------------------------------------------------------------------------- +# Backup node logic — runs on the backup before it upgrades +# --------------------------------------------------------------------------- +- name: "[CARP/backup] Verify primary peer is reachable before upgrading backup" + ansible.builtin.raw: echo "ping" + delegate_to: "{{ ha_peer }}" + register: _peer_reachable + failed_when: _peer_reachable.rc != 0 + when: ha_role == 'backup' + +- name: "[CARP/backup] Verify backup is in BACKUP state (all VIPs)" + ansible.builtin.raw: | + php -r 'require_once("/etc/inc/config.inc"); require_once("/etc/inc/interfaces.inc"); $ready = true; foreach(config_get_path("virtualip/vip", []) as $vip) { if ($vip["mode"] != "carp") continue; if (get_carp_interface_status("_vip" . $vip["uniqid"]) != "BACKUP") { $ready = false; break; } } echo $ready ? "BACKUP_READY" : "NOT_READY";' + register: _backup_carp_state + changed_when: false + when: ha_role == 'backup' + +- name: "[CARP/backup] Fail if backup is not fully in BACKUP state" + ansible.builtin.fail: + msg: "Backup node {{ inventory_hostname }} is not in BACKUP state for all CARP VIPs. State: {{ _backup_carp_state.stdout }}" + when: + - ha_role == 'backup' + - _backup_carp_state.stdout != "BACKUP_READY" + +- name: "[CARP/backup] Display CARP state" + ansible.builtin.debug: + msg: "CARP state on {{ inventory_hostname }} (backup): All VIPs are BACKUP" + when: ha_role == 'backup' + +# --------------------------------------------------------------------------- +# Primary node logic — runs on the primary before it upgrades +# --------------------------------------------------------------------------- + +# --- Step 1: Verify peer is reachable --- +- name: "[CARP/primary] Verify backup peer {{ ha_peer }} is reachable" + ansible.builtin.raw: echo "ping" + delegate_to: "{{ ha_peer }}" + register: _peer_reachable + failed_when: _peer_reachable.rc != 0 + when: + - ha_role == 'primary' + - ha_peer is defined + +# --- Step 2: Verify peer is on the upgraded version --- +- name: "[CARP/primary] Read version on backup peer {{ ha_peer }}" + ansible.builtin.raw: cat {{ pfsense_version_file }} + delegate_to: "{{ ha_peer }}" + register: _peer_version_raw + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Set peer version fact" + ansible.builtin.set_fact: + ha_peer_version: "{{ _peer_version_raw.stdout | trim }}" + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Warn if backup peer is not on newer version" + ansible.builtin.debug: + msg: | + ⚠ WARNING: Backup peer {{ ha_peer }} is running {{ ha_peer_version }}, + which is the SAME as primary ({{ pfsense_current_version }}). + + Primary upgrade requires backup to be on a newer version first. + Upgrade the backup node before upgrading primary. + when: + - ha_role == 'primary' + - ha_peer is defined + - ha_peer_version == pfsense_current_version + +- name: "[CARP/primary] Skip primary upgrade when backup not newer" + ansible.builtin.meta: end_play + when: + - ha_role == 'primary' + - ha_peer is defined + - ha_peer_version == pfsense_current_version + - perform_upgrade | bool + +# --- Step 3: Verify backup peer is in MASTER CARP state --- +- name: "[CARP/primary] Verify backup peer is MASTER for all CARP VIPs" + ansible.builtin.raw: | + ssh {{ ha_peer }} 'php -r "require_once(\"/etc/inc/config.inc\"); require_once(\"/etc/inc/interfaces.inc\"); $all_master = true; foreach(config_get_path(\"virtualip/vip\", []) as $vip) { if ($vip[\"mode\"] != \"carp\") continue; if (get_carp_interface_status(\"_vip\" . $vip[\"uniqid\"]) != \"MASTER\") { $all_master = false; break; } } echo $all_master ? \"ALL_MASTER\" : \"NOT_ALL_MASTER\";"' + register: _peer_carp_state_raw + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Fail if backup peer is not MASTER for all VIPs" + ansible.builtin.fail: + msg: > + Backup peer {{ ha_peer }} is not MASTER for all CARP VIPs. + State: {{ _peer_carp_state_raw.stdout }}. + Resolve CARP state before upgrading the primary. + when: + - ha_role == 'primary' + - ha_peer is defined + - _peer_carp_state_raw.stdout != "ALL_MASTER" + +# --- Step 4: Force demotion of primary --- +- name: "[CARP/primary] Force CARP demotion on this node (enter maintenance mode)" + ansible.builtin.raw: | + php -r 'require_once("/etc/inc/interfaces.inc"); interfaces_carp_set_maintenancemode(true);' + register: _carp_demotion + changed_when: true + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Wait for CARP failover to settle" + ansible.builtin.pause: + seconds: 30 + when: + - ha_role == 'primary' + - ha_peer is defined + +# --- Step 5: Re-verify backup peer has taken MASTER --- +- name: "[CARP/primary] Re-check backup peer MASTER state after demotion" + ansible.builtin.raw: | + ssh {{ ha_peer }} 'php -r "require_once(\"/etc/inc/config.inc\"); require_once(\"/etc/inc/interfaces.inc\"); $all_master = true; foreach(config_get_path(\"virtualip/vip\", []) as $vip) { if ($vip[\"mode\"] != \"carp\") continue; if (get_carp_interface_status(\"_vip\" . $vip[\"uniqid\"]) != \"MASTER\") { $all_master = false; break; } } echo $all_master ? \"ALL_MASTER\" : \"NOT_ALL_MASTER\";"' + register: _peer_carp_recheck + changed_when: false + when: + - ha_role == 'primary' + - ha_peer is defined + +- name: "[CARP/primary] Fail if backup peer did not take MASTER after demotion" + ansible.builtin.fail: + msg: > + Backup peer {{ ha_peer }} did not become MASTER for all VIPs after primary demotion. + State: {{ _peer_carp_recheck.stdout }}. + Investigate CARP before proceeding — primary has been demoted but backup is not MASTER. + when: + - ha_role == 'primary' + - ha_peer is defined + - _peer_carp_recheck.stdout != "ALL_MASTER" + +- name: "[CARP/primary] CARP demotion confirmed — backup is MASTER, safe to upgrade primary" + ansible.builtin.debug: + msg: > + {{ ha_peer }} is MASTER for all VIPs. {{ inventory_hostname }} is demoted. + Proceeding with primary upgrade. + when: + - ha_role == 'primary' + - ha_peer is defined \ No newline at end of file