Added pfsense upgrade roles

This commit is contained in:
Ben D.
2026-04-27 13:15:56 -07:00
parent 1e26dd304b
commit 03e889051e
35 changed files with 956 additions and 8 deletions

View File

@@ -0,0 +1,188 @@
---
# roles/pfsense_upgrade/tasks/carp.yml
# Handles CARP/HA awareness during upgrades.
# Only runs when ha_peer is defined.
#
# ha_role: backup → minimal pre-checks, upgrade proceeds normally
# ha_role: primary → full CARP state verification, forced demotion, peer version check
# ---------------------------------------------------------------------------
# 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] Check current CARP state on this node"
ansible.builtin.raw: >
php -r "require_once('/etc/inc/interfaces.inc');
\$carp = get_carp_status();
echo \$carp;"
register: _backup_carp_state
changed_when: false
when: ha_role == 'backup'
- name: "[CARP/backup] Display CARP state"
ansible.builtin.debug:
msg: "CARP state on {{ inventory_hostname }} (backup): {{ _backup_carp_state.stdout | trim }}"
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] Fail if backup peer is not on a newer version than primary"
ansible.builtin.fail:
msg: >
Backup peer {{ ha_peer }} is running {{ ha_peer_version }}, which is the same
as or older than this primary ({{ pfsense_current_version }}).
Upgrade the backup node first before proceeding with the primary.
when:
- ha_role == 'primary'
- ha_peer is defined
- ha_peer_version == pfsense_current_version
# --- Step 3: Verify backup peer is in MASTER CARP state ---
- name: "[CARP/primary] Check CARP state on backup peer {{ ha_peer }}"
ansible.builtin.raw: >
ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u
delegate_to: "{{ ha_peer }}"
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"
ansible.builtin.fail:
msg: >
Backup peer {{ ha_peer }} CARP state is '{{ _peer_carp_state_raw.stdout | trim }}'.
Expected MASTER. Resolve CARP state before upgrading the primary.
when:
- ha_role == 'primary'
- ha_peer is defined
- "'MASTER' not in _peer_carp_state_raw.stdout"
# --- Step 4: Force demotion of primary ---
- name: "[CARP/primary] Force CARP demotion on this node (set advskew to 254)"
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: 15
# --- Step 5: Re-verify backup peer has taken MASTER ---
- name: "[CARP/primary] Re-check CARP state on backup peer after demotion"
ansible.builtin.raw: >
ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u
delegate_to: "{{ ha_peer }}"
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 take MASTER after primary demotion.
CARP state is '{{ _peer_carp_recheck.stdout | trim }}'.
Investigate CARP before proceeding — primary has been demoted but backup is not MASTER.
when:
- ha_role == 'primary'
- ha_peer is defined
- "'MASTER' not in _peer_carp_recheck.stdout"
- name: "[CARP/primary] CARP demotion confirmed — backup is MASTER, safe to upgrade primary"
ansible.builtin.debug:
msg: >
{{ ha_peer }} is MASTER. {{ inventory_hostname }} is demoted.
Proceeding with primary upgrade.
when:
- ha_role == 'primary'
- ha_peer is defined
# ---------------------------------------------------------------------------
# Post-upgrade: restore CARP on primary and verify state
# ---------------------------------------------------------------------------
- name: "[CARP/primary] Re-enable CARP maintenance mode off (restore advskew)"
ansible.builtin.raw: >
php -r "
require_once('/etc/inc/interfaces.inc');
interfaces_carp_set_maintenancemode(false);
"
register: _carp_restore
changed_when: true
when:
- ha_role == 'primary'
- ha_peer is defined
- name: "[CARP/primary] Wait for CARP state to stabilize after restore"
ansible.builtin.pause:
seconds: 20
- name: "[CARP/primary] Verify primary has reclaimed MASTER"
ansible.builtin.raw: >
ifconfig | grep -o 'carp: [A-Z]*' | awk '{print $2}' | sort -u
register: _primary_carp_final
changed_when: false
when:
- ha_role == 'primary'
- ha_peer is defined
- name: "[CARP/primary] Warn if primary did not reclaim MASTER"
ansible.builtin.debug:
msg: >
WARNING: Primary CARP state is '{{ _primary_carp_final.stdout | trim }}' — expected MASTER.
This may resolve on its own. Check CARP status on both nodes manually.
when:
- ha_role == 'primary'
- ha_peer is defined
- "'MASTER' not in _primary_carp_final.stdout"
- name: "[CARP/primary] CARP state confirmed restored"
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has reclaimed MASTER. HA pair is fully operational."
when:
- ha_role == 'primary'
- ha_peer is defined
- "'MASTER' in _primary_carp_final.stdout"

View File

@@ -0,0 +1,36 @@
---
# roles/pfsense_upgrade/tasks/main.yml
- name: Include pre-flight checks
ansible.builtin.import_tasks: preflight.yml
tags: [always, preflight, check]
- name: Include version detection
ansible.builtin.import_tasks: version_detect.yml
tags: [always, check]
- name: Include update check
ansible.builtin.import_tasks: update_check.yml
tags: [always, check]
- name: Include CARP/HA pre-upgrade logic
ansible.builtin.import_tasks: carp.yml
tags: [always, check, carp]
when: ha_peer is defined
- name: Include upgrade execution
ansible.builtin.import_tasks: upgrade.yml
tags: [upgrade]
when: perform_upgrade | bool
- name: Include CARP/HA post-upgrade restore
ansible.builtin.import_tasks: carp.yml
tags: [upgrade, carp]
when:
- ha_peer is defined
- perform_upgrade | bool
- name: Include post-upgrade verification
ansible.builtin.import_tasks: verify.yml
tags: [upgrade, verify]
when: perform_upgrade | bool

View File

@@ -0,0 +1,41 @@
---
# roles/pfsense_upgrade/tasks/preflight.yml
# Validate SSH connectivity, confirm host is pfSense, check disk space.
- name: Verify SSH connectivity to pfSense host
ansible.builtin.raw: echo "ping"
register: _ssh_test
changed_when: false
failed_when: _ssh_test.rc != 0
- name: Confirm host is running pfSense (check version file)
ansible.builtin.raw: test -f {{ pfsense_version_file }} && echo "pfsense_ok"
register: _pfsense_check
changed_when: false
failed_when: "'pfsense_ok' not in _pfsense_check.stdout"
- name: Check available disk space on root filesystem (must be ≥ 200 MB)
ansible.builtin.raw: >
df -m / | awk 'NR==2 {print $4}'
register: _disk_avail
changed_when: false
- name: Fail if disk space is insufficient
ansible.builtin.fail:
msg: >
Host {{ inventory_hostname }} only has {{ _disk_avail.stdout | trim }} MB free on /.
At least 200 MB is required to safely upgrade pfSense.
when: (_disk_avail.stdout | trim | int) < 200
- name: Check that pfSense-upgrade binary exists
ansible.builtin.raw: test -x {{ pfsense_upgrade_bin }} && echo "bin_ok"
register: _bin_check
changed_when: false
failed_when: "'bin_ok' not in _bin_check.stdout"
- name: Pre-flight summary
ansible.builtin.debug:
msg: >
Pre-flight OK — {{ inventory_hostname }}:
disk free={{ _disk_avail.stdout | trim }}MB,
pfSense-upgrade binary present.

View File

@@ -0,0 +1,149 @@
---
# roles/pfsense_upgrade/tasks/update_check.yml
# Checks for available upgrades using pfSense-upgrade -c and pkg version.
# Also queries upstream for the latest stable release on this branch.
# ---------------------------------------------------------------------------
# 1. Refresh the local pkg repository metadata
# ---------------------------------------------------------------------------
- name: Update pkg repository metadata
ansible.builtin.raw: pkg update -f 2>&1
register: _pkg_update
changed_when: false
when: pkg_repo_update | bool
timeout: "{{ upgrade_check_timeout }}"
# ---------------------------------------------------------------------------
# 2. Run pfSense-upgrade in check-only mode
# ---------------------------------------------------------------------------
- name: Run pfSense-upgrade --check (dry run)
ansible.builtin.raw: >
{{ pfsense_upgrade_bin }} -d -c 2>&1
register: _upgrade_check
changed_when: false
timeout: "{{ upgrade_check_timeout }}"
# pfSense-upgrade exits 0 when up-to-date, non-zero when upgrade available.
# We capture both cases.
failed_when: false
- name: Parse upgrade check output
ansible.builtin.set_fact:
upgrade_check_stdout: "{{ _upgrade_check.stdout | trim }}"
upgrade_check_rc: "{{ _upgrade_check.rc }}"
# True if the tool reports an update is available
upgrade_available: >-
{{
_upgrade_check.rc != 0 or
'Upgraded' in _upgrade_check.stdout or
'update' in _upgrade_check.stdout | lower and
'up to date' not in _upgrade_check.stdout | lower
}}
# Attempt to extract the new version string from the upgrade check output
# pfSense-upgrade typically prints: "pfSense-upgrade: New version available: 2.7.3-RELEASE"
upgrade_available_version: >-
{{
_upgrade_check.stdout
| regex_search('(\d+\.\d+\.\d+[-a-zA-Z0-9]*)', '\1')
| first | default('unknown')
}}
# ---------------------------------------------------------------------------
# 3. Check pkg for pending package updates (captures sub-component updates)
# ---------------------------------------------------------------------------
- name: Check for pending pkg upgrades (outdated packages)
ansible.builtin.raw: pkg version -l '<' 2>&1 | head -40
register: _pkg_outdated
changed_when: false
failed_when: false
- name: Count outdated packages
ansible.builtin.set_fact:
pkg_outdated_count: "{{ _pkg_outdated.stdout_lines | reject('match', '^\\s*$') | list | length }}"
pkg_outdated_list: "{{ _pkg_outdated.stdout | trim }}"
# ---------------------------------------------------------------------------
# 4. Detect the latest stable release for this branch via GitHub
# ---------------------------------------------------------------------------
- name: Fetch latest stable release version from Netgate/pfSense repo
ansible.builtin.raw: >
fetch -q -o - "{{ pfsense_release_url }}" 2>/dev/null || echo "fetch_failed"
register: _upstream_version_raw
changed_when: false
failed_when: false
- name: Parse upstream latest stable version
ansible.builtin.set_fact:
upstream_version: "{{ _upstream_version_raw.stdout | trim }}"
upstream_fetch_ok: "{{ 'fetch_failed' not in _upstream_version_raw.stdout }}"
- name: Derive upstream branch (major.minor)
ansible.builtin.set_fact:
upstream_major_minor: >-
{{
upstream_version
| regex_replace('^(\d+\.\d+).*$', '\1')
| default(pfsense_major_minor)
}}
when: upstream_fetch_ok | bool
# ---------------------------------------------------------------------------
# 5. Compare branches — detect if a newer stable branch exists upstream
# ---------------------------------------------------------------------------
- name: Determine if a newer major release branch is available
ansible.builtin.set_fact:
new_major_release_available: >-
{{
upstream_fetch_ok | bool and
(upstream_major_minor | string) != (pfsense_major_minor | string) and
(upstream_major_minor.split('.')[0] | int > pfsense_major_minor.split('.')[0] | int) or
(upstream_major_minor.split('.')[0] | int == pfsense_major_minor.split('.')[0] | int and
upstream_major_minor.split('.')[1] | int > pfsense_major_minor.split('.')[1] | int)
}}
when: upstream_fetch_ok | bool
- name: Default new_major_release_available when fetch failed
ansible.builtin.set_fact:
new_major_release_available: false
when: not (upstream_fetch_ok | bool)
# ---------------------------------------------------------------------------
# 6. Print the full update status report
# ---------------------------------------------------------------------------
- name: Display update status report
ansible.builtin.debug:
msg:
- "============================================================"
- " Update Status: {{ inventory_hostname }}"
- "============================================================"
- " Current version : {{ pfsense_current_version }}"
- " Current branch : {{ pfsense_major_minor }}"
- "------------------------------------------------------------"
- " In-branch update : {{ 'YES — ' ~ upgrade_available_version if upgrade_available | bool else 'No — already up to date' }}"
- " Outdated pkgs : {{ pkg_outdated_count }} package(s) behind"
- "------------------------------------------------------------"
- " Upstream latest : {{ upstream_version if upstream_fetch_ok | bool else 'Could not reach upstream' }}"
- " Upstream branch : {{ upstream_major_minor if upstream_fetch_ok | bool else 'N/A' }}"
- " New branch avail : {{ 'YES — ' ~ upstream_version if new_major_release_available | bool else 'No' }}"
- "------------------------------------------------------------"
- " perform_upgrade : {{ perform_upgrade | bool }}"
- " allow_major_upg : {{ allow_major_upgrade | bool }}"
- "============================================================"
- name: Warn if a new major release branch is available but not allowed
ansible.builtin.debug:
msg: >
WARNING: pfSense {{ upstream_version }} is available on branch {{ upstream_major_minor }},
which is newer than your running branch {{ pfsense_major_minor }}.
To upgrade across branches, re-run with: -e "perform_upgrade=true allow_major_upgrade=true"
when:
- new_major_release_available | bool
- not (allow_major_upgrade | bool)
- name: Warn if perform_upgrade is false but upgrades are available
ansible.builtin.debug:
msg: >
DRY RUN — upgrades are available but perform_upgrade=false.
Re-run with -e "perform_upgrade=true" to apply.
when:
- (upgrade_available | bool) or (pkg_outdated_count | int > 0)
- not (perform_upgrade | bool)

View File

@@ -0,0 +1,100 @@
---
# roles/pfsense_upgrade/tasks/upgrade.yml
# Applies the upgrade after safety checks pass.
# Only runs when perform_upgrade=true.
- name: Abort if no upgrade is available (nothing to do)
ansible.builtin.debug:
msg: >
No in-branch upgrade is available for {{ inventory_hostname }}.
Current version {{ pfsense_current_version }} is already the latest on branch {{ pfsense_major_minor }}.
Skipping upgrade.
when:
- not (upgrade_available | bool)
- not (new_major_release_available | bool and allow_major_upgrade | bool)
- name: End play for this host if nothing to upgrade
ansible.builtin.meta: end_host
when:
- not (upgrade_available | bool)
- not (new_major_release_available | bool and allow_major_upgrade | bool)
# ---------------------------------------------------------------------------
# Branch-crossing guard
# ---------------------------------------------------------------------------
- name: Abort if major upgrade is available but not explicitly allowed
ansible.builtin.fail:
msg: >
A new pfSense branch is available ({{ upstream_version }}) but allow_major_upgrade=false.
Review the release notes for {{ upstream_version }} before upgrading across branches.
Re-run with -e "allow_major_upgrade=true" when ready.
when:
- new_major_release_available | bool
- not (allow_major_upgrade | bool)
- not (upgrade_available | bool)
# ---------------------------------------------------------------------------
# Pre-upgrade config backup
# ---------------------------------------------------------------------------
- name: Trigger config backup via PHP (writes to /cf/conf/backup/)
ansible.builtin.raw: >
php -r "require_once('/etc/inc/config.inc');
require_once('/etc/inc/util.inc');
backup_config();"
register: _backup_result
changed_when: false
when: not (skip_backup_check | bool)
- name: Confirm backup file was created
ansible.builtin.raw: >
ls -t {{ pfsense_config_backup_path }}/config-*.xml 2>/dev/null | head -1
register: _backup_file
changed_when: false
when: not (skip_backup_check | bool)
- name: Display backup file path
ansible.builtin.debug:
msg: "Config backup written to: {{ _backup_file.stdout | trim }}"
when:
- not (skip_backup_check | bool)
- _backup_file.stdout | trim | length > 0
- name: Warn if no backup file found
ansible.builtin.debug:
msg: >
WARNING: Could not confirm config backup was written.
Check {{ pfsense_config_backup_path }} manually before proceeding.
when:
- not (skip_backup_check | bool)
- _backup_file.stdout | trim | length == 0
# ---------------------------------------------------------------------------
# Execute the upgrade
# ---------------------------------------------------------------------------
- name: "UPGRADE — Running pfSense-upgrade on {{ inventory_hostname }}"
ansible.builtin.raw: >
{{ pfsense_upgrade_bin }} -d -y 2>&1
register: _upgrade_result
async: 600 # pfSense upgrades can take several minutes
poll: 10
timeout: 620
# The upgrade reboots the host — the connection will drop. That is expected.
failed_when: >
_upgrade_result.rc is defined and
_upgrade_result.rc != 0 and
'reboot' not in _upgrade_result.stdout | lower and
'Restarting' not in _upgrade_result.stdout
- name: Display upgrade output
ansible.builtin.debug:
msg: "{{ _upgrade_result.stdout_lines | default(['(no output captured — likely rebooted mid-stream)']) }}"
# ---------------------------------------------------------------------------
# Wait for host to come back after reboot
# ---------------------------------------------------------------------------
- name: Wait for pfSense to reboot and become reachable
ansible.builtin.wait_for_connection:
delay: 30
timeout: "{{ reboot_timeout }}"
sleep: 10
when: auto_reboot | bool

View File

@@ -0,0 +1,62 @@
---
# roles/pfsense_upgrade/tasks/verify.yml
# Verifies the system is healthy after upgrade and reports the new version.
- name: Wait an additional grace period before verifying
ansible.builtin.pause:
seconds: 15
- name: Read post-upgrade version
ansible.builtin.raw: cat {{ pfsense_version_file }}
register: _new_version_raw
changed_when: false
retries: 3
delay: 10
- name: Set post-upgrade version fact
ansible.builtin.set_fact:
pfsense_new_version: "{{ _new_version_raw.stdout | trim }}"
- name: Verify pfSense web GUI is responding (port 443)
ansible.builtin.raw: >
fetch -q -o /dev/null --no-verify-peer https://127.0.0.1/ 2>&1 || true
register: _webgui_check
changed_when: false
failed_when: false
- name: Check that key pfSense services are running
ansible.builtin.raw: >
sockstat -l | grep -E ':(53|443|80)\b' | wc -l | tr -d ' '
register: _services_check
changed_when: false
failed_when: false
- name: Run pfSense-upgrade --check post-upgrade (confirm up-to-date)
ansible.builtin.raw: >
{{ pfsense_upgrade_bin }} -d -c 2>&1
register: _post_upgrade_check
changed_when: false
failed_when: false
- name: Upgrade result summary
ansible.builtin.debug:
msg:
- "============================================================"
- " Upgrade Result: {{ inventory_hostname }}"
- "============================================================"
- " Previous version : {{ pfsense_current_version }}"
- " New version : {{ pfsense_new_version }}"
- " Version changed : {{ pfsense_current_version != pfsense_new_version }}"
- " Listening ports : {{ _services_check.stdout | trim }} found (DNS/HTTP/HTTPS)"
- " Post-upg check : {{ 'Up to date' if _post_upgrade_check.rc == 0 else 'May still have pending updates' }}"
- "============================================================"
- name: Fail if version did not change after upgrade attempt
ansible.builtin.fail:
msg: >
pfSense version on {{ inventory_hostname }} is still {{ pfsense_new_version }}
after upgrade attempt (was {{ pfsense_current_version }}).
The upgrade may not have applied correctly — check the host manually.
when:
- pfsense_current_version == pfsense_new_version
- upgrade_available | bool

View File

@@ -0,0 +1,70 @@
---
# roles/pfsense_upgrade/tasks/version_detect.yml
# Reads version info from the running pfSense host and sets facts.
- name: Read current pfSense version string
ansible.builtin.raw: cat {{ pfsense_version_file }}
register: _raw_version
changed_when: false
- name: Read patch level (if present)
ansible.builtin.raw: >
test -f {{ pfsense_version_patch_file }} && cat {{ pfsense_version_patch_file }} || echo "0"
register: _raw_patch
changed_when: false
- name: Read build timestamp (if present)
ansible.builtin.raw: >
test -f {{ pfsense_version_buildtime }} && cat {{ pfsense_version_buildtime }} || echo "unknown"
register: _raw_buildtime
changed_when: false
- name: Read pfSense edition (CE vs Plus)
ansible.builtin.raw: >
pkg info pfSense 2>/dev/null | grep -i '^Name' | awk '{print $3}' || echo "pfSense"
register: _raw_edition
changed_when: false
- name: Detect CPU architecture
ansible.builtin.raw: uname -m
register: _raw_arch
changed_when: false
- name: Set version facts
ansible.builtin.set_fact:
pfsense_current_version: "{{ _raw_version.stdout | trim }}"
pfsense_current_patch: "{{ _raw_patch.stdout | trim }}"
pfsense_build_time: "{{ _raw_buildtime.stdout | trim }}"
pfsense_edition: "{{ _raw_edition.stdout | trim }}"
pfsense_arch: "{{ _raw_arch.stdout | trim }}"
# Parse major.minor from version string (e.g. "2.7.2-RELEASE" → "2.7")
pfsense_major_minor: >-
{{
(_raw_version.stdout | trim)
| regex_replace('^(\d+\.\d+).*$', '\1')
}}
# Parse patch version integer (e.g. "2.7.2-RELEASE" → 2)
pfsense_patch_int: >-
{{
(_raw_version.stdout | trim)
| regex_replace('^(\d+)\.(\d+)\.(\d+).*$', '\3')
| default('0')
}}
# Determine if this is a RELEASE, RC, BETA, or ALPHA
pfsense_release_type: >-
{{
(_raw_version.stdout | trim)
| regex_replace('^.*-(RELEASE|RC\d*|BETA\d*|ALPHA\d*|DEVELOPMENT).*$', '\1')
| default('UNKNOWN')
}}
- name: Display detected version info
ansible.builtin.debug:
msg:
- "Host : {{ inventory_hostname }}"
- "Edition : {{ pfsense_edition }}"
- "Version : {{ pfsense_current_version }}"
- "Branch : {{ pfsense_major_minor }}"
- "Release type : {{ pfsense_release_type }}"
- "Build time : {{ pfsense_build_time }}"
- "Architecture : {{ pfsense_arch }}"