AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails
Preconditions on capacity and health
What you'll learn
- Write a capacity precondition against gathered facts with a message an operator can act on
- State the age and source of the evidence behind every precondition
- Explain how fact caching can make a guard pass on three-day-old data
- Choose between a gathered fact, a live probe and an external system for a given check
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
The guards so far have asserted on things the controller knows for certain: which inventory this is, which groups the host is in, how many hosts the play resolved to. Those facts cannot be stale, because they were read from disk a second ago.
Capacity and health guards are different, and the difference is the whole
lesson. free disk > 20 GB is not a fact about the world. It is a fact
about some measurement of the world, taken at some time, by some
mechanism. The assertion is worth exactly as much as that measurement,
and a guard nobody has questioned on those terms is decoration.
The four checks worth building
| Check | The change it protects | Where the evidence comes from |
|---|---|---|
| Capacity | Anything that writes: a package upgrade, a log-heavy migration, a database import | Gathered facts (ansible_facts.mounts) |
| Service health | Anything that restarts a service, or takes a node out of rotation | A live probe of the service or its health endpoint |
| Cluster quorum | Draining, rebooting or removing a node from a cluster | The cluster API, queried once from the controller |
| Backup freshness | Anything irreversible | The backup system, not the playbook’s belief about it |
Capacity, from gathered facts
ansible_facts.mounts is a list of dictionaries, one per mount, each
carrying size_total and size_available in bytes.
- name: Refuse if the root filesystem is too full for the upgrade
ansible.builtin.assert:
that:
- root_free_bytes | int > (required_gb | int * 1024 * 1024 * 1024)
fail_msg: >-
Refusing: / on {{ inventory_hostname }} has
{{ (root_free_bytes | int / 1024 / 1024 / 1024) | round(1) }} GB
free and this upgrade needs {{ required_gb }} GB. Clear space or
exclude this host with --limit.
success_msg: 'Capacity precondition met on /.'
vars:
root_free_bytes: >-
{{ ansible_facts.mounts | selectattr('mount', 'equalto', '/')
| map(attribute='size_available') | first }}
$ ansible-playbook -i inv.ini upgrade.ymlTASK [Refuse if the root filesystem is too full for the upgrade] ***************
fatal: [localhost]: FAILED! => {
"assertion": "root_free_bytes | int > (required_gb | int * 1024 * 1024 * 1024)",
"changed": false,
"evaluated_to": false,
"msg": "Refusing: / has 63.9 GB free and this upgrade needs 488.3 GB. Clear space or exclude this host with --limit."
}The message reports the observed value, not just the rule. An operator who sees 63.9 GB free, 488.3 GB needed knows immediately whether to clear space or to question the requirement; an operator who sees insufficient disk space has to go and measure it themselves.
The question that makes a guard real
For every precondition, two questions:
Where did this fact come from? A gathered fact, a live probe, a
registered result from earlier in the play, an external system, or a
variable somebody typed into group_vars in 2024.
How old is it? Seconds, or days.
The second question has a specific and unpleasant answer whenever fact caching is enabled.
$ ansible-config dump | grep ^CACHE_PLUGINCACHE_PLUGIN(default) = memory
CACHE_PLUGIN_CONNECTION(default) = None
CACHE_PLUGIN_PREFIX(default) = ansible_facts
CACHE_PLUGIN_TIMEOUT(default) = 86400The names in ansible.cfg differ from the internal names, which is worth
knowing when you go looking:
[defaults]
fact_caching = jsonfile
fact_caching_connection = /var/cache/ansible/facts
fact_caching_timeout = 86400
CACHE_PLUGIN defaults to memory, which is per-run and therefore
always fresh. The moment somebody sets fact_caching to jsonfile or
redis — usually for a good reason, because gathering facts on three
thousand hosts is slow — every gathered fact in every guard becomes up
to fact_caching_timeout seconds old. The default is 86400: one full
day.
Freshness as its own assertion
Backup freshness is the clearest case, because the value being checked is a time.
- name: Refuse if the last backup is older than this change is reversible
ansible.builtin.assert:
that:
- backup_age_hours | float < max_backup_age_hours | float
fail_msg: >-
Refusing: the last successful backup of {{ inventory_hostname }}
completed {{ backup_age_hours }} hours ago
({{ backup_completed_at }}) and this change is not reversible.
Take a backup first, or set -e skip_backup_check=true and record
why in the change ticket.
success_msg: >-
Backup precondition met: last backup {{ backup_age_hours }} hours
old, limit {{ max_backup_age_hours }}.
vars:
backup_age_hours: >-
{{ (((ansible_facts.date_time.epoch | int) -
((backup_completed_at | to_datetime('%Y-%m-%dT%H:%M:%SZ')).strftime('%s') | int))
/ 3600) | round(1) }}
$ ansible-playbook -i inv.ini migrate.ymlTASK [Refuse if the last backup is older than this change is reversible] *******
fatal: [localhost]: FAILED! => {
"assertion": "backup_age_hours | float < max_backup_age_hours | float",
"changed": false,
"evaluated_to": false,
"msg": "Refusing: the last successful backup of localhost completed 91.6 hours ago (2026-08-08T02:14:00Z) and this change is not reversible. Take a backup first, or set -e skip_backup_check=true and record why in the change ticket."
}The critical design point is where backup_completed_at comes from. If
it is a variable in group_vars, the guard is theatre — it checks a
number a human wrote down. It is only a guard if the value is queried
from the backup system in this run:
- name: Ask the backup system when this host was last backed up
ansible.builtin.uri:
url: 'https://backups.example.com/api/v1/hosts/{{ inventory_hostname }}/latest'
headers:
Authorization: 'Bearer {{ backup_api_token }}'
return_content: true
register: backup_status
delegate_to: localhost
check_mode: false
no_log: true
delegate_to: localhost because the controller has the credential and
the target does not. check_mode: false because this is a read-only
query that must still run during a --check — the check-mode part
explains why that keyword is load-bearing here. no_log: true because
the URL carries a bearer token.
Health and quorum: ask once, not once per host
A cluster has one health state. Querying it two hundred times is two hundred chances to be rate-limited and one answer.
- name: Read cluster health once, from the controller
ansible.builtin.uri:
url: 'https://cluster.example.com/api/health'
return_content: true
register: cluster
run_once: true
delegate_to: localhost
check_mode: false
- name: Refuse to drain a node from a cluster that is already degraded
ansible.builtin.assert:
that:
- cluster.json.status == 'healthy'
- cluster.json.nodes_unavailable | int == 0
fail_msg: >-
Refusing to drain {{ inventory_hostname }}: the cluster reports
'{{ cluster.json.status }}' with
{{ cluster.json.nodes_unavailable }} nodes already unavailable.
Draining another node risks quorum.
The assert deliberately is not run_once. The probe is a fact
about the cluster and is read once; the decision is about this host and
is made per host — so a serial rollout re-evaluates it for every batch,
which is exactly what you want when the cluster degrades halfway through.
Knowledge check
Knowledge check · 4 questions
Q1. Fact caching is enabled with default settings and a play runs with gather_facts: false. A disk-capacity guard reads ansible_facts.mounts and passes. What has it established?
Q2. A cluster health probe is written with uri, run_once: true and delegate_to: localhost, and the assert that consumes it is not run_once. Why? Select all that apply.
Q3. A guard that asserts on / is a sufficient capacity check for a change that writes into /var/lib/postgresql.
Q4. The health API returns a connection error rather than an unhealthy status. What should the guard do?
Passing score: 75%. Answers are checked in this browser.