AnsibleXLVIII · Maintenance Windows and RollbackMaintenance windows and rollback
Running the window
What you'll learn
- Build a pre-flight play that gates the window on connectivity, freeze status and backup freshness
- Run the dry run against the exact host list the window will use, not an approximation of it
- Operate an explicit go/no-go gate rather than starting because the clock says so
- Produce the window report that makes the next window better than this one
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A maintenance window is a control if it can stop the change. If nothing that happens inside it can result in the change not proceeding, it is not a control — it is a calendar entry with an on-call rota attached.
That distinction is the whole lesson. Most windows fail it, not because anybody decided they should, but because no phase of the window was given the authority to say no.
Five phases
| Phase | What it does | What it produces |
|---|---|---|
| Pre-check | Asserts the conditions the change assumes | A pass, or a cancelled window |
| Change | Applies the change, in batches, with gates | A changed fleet, or an abort |
| Validation | Proves the outcome, not the tasks | Evidence, or a rollback trigger |
| Rollback trigger | Fires the reverse when criteria are met | A restored fleet |
| Reporting | Records what happened, host by host | The input to the next window |
Each phase can stop the window. That is the property that makes them phases rather than a checklist.
Phase 1 — pre-check
The pre-check play asserts everything the change assumes and refuses to let the window open if any of it is false. It changes nothing, so it can be run repeatedly — an hour before, and again at the top of the window.
- name: Pre-flight gate for the change window
hosts: appservers
gather_facts: true
any_errors_fatal: true
vars:
change_window_id: CHG-2026-08-14-001
max_backup_age_hours: 24
tasks:
- name: Every targeted host answers
ansible.builtin.ping:
- name: Change freeze is not in force
ansible.builtin.assert:
that:
- not freeze_active | bool
fail_msg: "Change freeze active - {{ change_window_id }} may not proceed"
quiet: true
run_once: true
- name: Root filesystem has room for the change
ansible.builtin.assert:
that:
- (ansible_facts['mounts'] | selectattr('mount', 'eq', '/')
| map(attribute='size_available') | first) > 2147483648
fail_msg: "Less than 2 GiB free on / on {{ inventory_hostname }}"
quiet: true
- name: Backup on this host is fresh enough to be a rollback
ansible.builtin.assert:
that:
- backup_age_hours | int < max_backup_age_hours | int
fail_msg: >-
Backup on {{ inventory_hostname }} is {{ backup_age_hours }}h old,
limit is {{ max_backup_age_hours }}h
success_msg: "Backup {{ backup_age_hours }}h old"Run against ten hosts where one has a stale backup, this is what a blocked window looks like:
$ ansible-playbook -i inventories/prod preflight.yml; echo "exit=$?"TASK [Every targeted host answers] *********************************************
ok: [web01]
ok: [web02]
ok: [web03]
TASK [Change freeze is not in force] *******************************************
ok: [web01] => {
"changed": false,
"msg": "All assertions passed"
}
TASK [Backup on this host is fresh enough to be a rollback] ********************
ok: [web01] => {
"changed": false,
"msg": "Backup 3h old"
}
fatal: [web03]: FAILED! => {
"changed": false,
"msg": "Backup on web03 is 216h old, limit is 24h"
}
PLAY RECAP *********************************************************************
web01 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03 : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
exit=2Exit 2. The window does not open. One host out of ten has a backup that
predates the last week, so the reverse procedure for that host is not
available, so the change does not run — on any host, because
any_errors_fatal: true makes one host’s failure the play’s failure.
Phase 2 — the dry run against the exact host list
The dry run has to target the host list the window will use. Not the group, not the pattern from last time, not “prod minus the ones we know about”. The exact list.
# 1. What does the pattern actually resolve to?
ansible-playbook -i inventories/prod site.yml \
--limit 'appservers:!maintenance' --list-hosts
# 2. What tasks will run?
ansible-playbook -i inventories/prod site.yml \
--limit 'appservers:!maintenance' --list-tasks
# 3. What would change? Read the diff, not the recap.
ansible-playbook -i inventories/prod site.yml \
--limit 'appservers:!maintenance' --check --diffThe reason for the discipline: a --limit that differs by one host
between the dry run and the real run means the dry run examined a
different change. That sounds pedantic until the extra host is the one
with the hand-edited config.
Phase 3 — the go/no-go gate
An explicit decision, made by a named person, after reading the pre-check result and the dry run, and before the first task that changes anything.
It is worth being blunt about what this is defending against. The default behaviour of a maintenance window is that the change proceeds because the window has started. The gate exists to make proceeding an act rather than an absence.
Three things make a gate real:
- A named decider, who is present. Not a role, a person.
- Written inputs: the pre-check exit code, the dry-run change count, the confirmation that the reverse procedure and its backups exist.
- The ability to say no with no cost. If cancelling at the gate requires an apology, the gate will always say yes.
The gate is also where the timebox is agreed out loud: this window ends at 03:30, and at 03:30 an incomplete change is rolled back rather than continued. Fixing the end time before starting is what stops the “we are nearly there” spiral at 04:15.
Phase 4 — validation, and the trigger
Validation is a separate play that runs after each batch, and it tests outcomes rather than tasks. That distinction has its own lesson in Part XLIX; the window-level rule is simpler:
A validation failure is a change failure. Not a note for the morning.
The validation play exits non-zero, the abort criteria have been met, and the reverse procedure runs. Making that automatic rather than discretionary is lesson 5.
Phase 5 — reporting
The report is written because the next window is planned from it.
| Field | Why it is in the report |
|---|---|
| Host list, as resolved | The next window starts from a known population |
| Per-host outcome | Distinguishes changed, unchanged, failed, never attempted |
| Batch timings | Turns “about an hour” into a number for the next plan |
| Deviations from the plan | The most valuable field and the most often omitted |
| Whether rollback was used, and how long it took | Validates or refutes the estimate in the plan |
| Hosts left in an intermediate state | The input to lesson 7 |
Knowledge check
Knowledge check · 4 questions
Q1. A change play aborts partway through the second of four batches. The PLAY RECAP shows four hosts. The window report is written from that recap. What is wrong with it?
Q2. Which of these belong in a pre-check play that gates a maintenance window? Select all that apply.
Q3. With any_errors_fatal: true, Ansible finishes the current task across every host in the play or batch before ending, so a failing pre-check reports all the bad hosts rather than only the first one.
Q4. Why must the --check --diff dry run use the exact --limit string the change will use?
Passing score: 75%. Answers are checked in this browser.