Skip to main content
RunBook Academy

AnsibleXXIV · Assertions and GuardrailsAssertions and guardrails

Preconditions on capacity and health

Advanced⏱ ~25 minansible-playbookansible-config

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

Not yet marked complete on this device.

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

CheckThe change it protectsWhere the evidence comes from
CapacityAnything that writes: a package upgrade, a log-heavy migration, a database importGathered facts (ansible_facts.mounts)
Service healthAnything that restarts a service, or takes a node out of rotationA live probe of the service or its health endpoint
Cluster quorumDraining, rebooting or removing a node from a clusterThe cluster API, queried once from the controller
Backup freshnessAnything irreversibleThe 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 }}
Service impact possiblea capacity guard refusing
$ ansible-playbook -i inv.ini upgrade.yml
TASK [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.

Read-only / Safethe cache settings that decide the answer
$ ansible-config dump | grep ^CACHE_PLUGIN
CACHE_PLUGIN(default) = memory
CACHE_PLUGIN_CONNECTION(default) = None
CACHE_PLUGIN_PREFIX(default) = ansible_facts
CACHE_PLUGIN_TIMEOUT(default) = 86400

The 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) }}
Service impact possiblea stale backup refusing the change
$ ansible-playbook -i inv.ini migrate.yml
TASK [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

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

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

  3. Q3. A guard that asserts on / is a sufficient capacity check for a change that writes into /var/lib/postgresql.

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