Skip to main content
RunBook Academy

AnsibleXLVIII · Maintenance Windows and RollbackMaintenance windows and rollback

Running the window

Advanced⏱ ~28 minansible-playbook

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

Not yet marked complete on this device.

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

PhaseWhat it doesWhat it produces
Pre-checkAsserts the conditions the change assumesA pass, or a cancelled window
ChangeApplies the change, in batches, with gatesA changed fleet, or an abort
ValidationProves the outcome, not the tasksEvidence, or a rollback trigger
Rollback triggerFires the reverse when criteria are metA restored fleet
ReportingRecords what happened, host by hostThe 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.

Read-only / Safepreflight.yml
- 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:

Read-only / Safethe pre-check refusing the window
$ 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=2

Exit 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.

Read-only / Safeestablish the host list, then dry-run it
# 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 --diff

The 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:

  1. A named decider, who is present. Not a role, a person.
  2. Written inputs: the pre-check exit code, the dry-run change count, the confirmation that the reverse procedure and its backups exist.
  3. 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.

FieldWhy it is in the report
Host list, as resolvedThe next window starts from a known population
Per-host outcomeDistinguishes changed, unchanged, failed, never attempted
Batch timingsTurns “about an hour” into a number for the next plan
Deviations from the planThe most valuable field and the most often omitted
Whether rollback was used, and how long it tookValidates or refutes the estimate in the plan
Hosts left in an intermediate stateThe input to lesson 7

Knowledge check

Knowledge check · 4 questions

  1. 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?

  2. Q2. Which of these belong in a pre-check play that gates a maintenance window? Select all that apply.

  3. 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.

  4. 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.