Skip to main content
RunBook Academy

AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates

Validation plays that fail loudly

Advanced⏱ ~30 minansible-playbook

What you'll learn

  • Write a validation play that tests operational health rather than task success
  • Explain why a list given to failed_when is joined with an implicit and, and what that breaks
  • Choose between assert, fail and failed_when for a given check
  • Apply the rule that a validation failure is a change failure

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 gap this course keeps returning to:

template succeeded. systemctl restart succeeded. The HTTP health check fails. The play reported success.

Every task did what it was asked. The file was written, the unit was restarted, systemd reports the service as active, and the application returns 500 because the new config has a typo in the database host. Nothing in the play noticed, because nothing in the play asked.

A validation play is what asks. It runs after a change, it tests operational health rather than task outcomes, and its failure is a change failure.

The six checks

Almost every validation play is some subset of these, and the value is in running the whole subset rather than the first one.

CheckMechanismWhat it catches that the previous one does not
Package installed at the expected versionpackage_factsA deploy that did not deploy
Config file correctslurp or stat plus an assertionA template that rendered wrong
Service runningservice_factsA unit that failed after starting
Port listeningwait_forA service that is running but not bound
Endpoint healthyuriA process that is bound but broken
Certificate valid and not near expiryx509_certificate_infoA working service about to stop working

The ordering is deliberate: each row is closer to what a user experiences, and each row can pass while the one below it fails.

Read-only / Safevalidate.yml
- name: Validate the application after a change
hosts: appservers
gather_facts: true
tasks:
  - name: Collect package and service state
    ansible.builtin.package_facts:
      manager: auto
    check_mode: false

  - name: Collect service state
    ansible.builtin.service_facts:
    check_mode: false

  - name: The expected version is installed
    ansible.builtin.assert:
      that:
        - "'acme-app' in ansible_facts.packages"
        - ansible_facts.packages['acme-app']
          | map(attribute='version') | first is version(expected_version, '==')
      fail_msg: >-
        {{ inventory_hostname }} has
        {{ ansible_facts.packages['acme-app'] | default([]) | map(attribute='version') | join(',') }},
        expected {{ expected_version }}
      quiet: true

  - name: The service is running and enabled
    ansible.builtin.assert:
      that:
        - ansible_facts.services['acme-app.service'].state == 'running'
        - ansible_facts.services['acme-app.service'].status == 'enabled'
      fail_msg: "acme-app is not running and enabled on {{ inventory_hostname }}"
      quiet: true

  - name: The service is listening
    ansible.builtin.wait_for:
      port: 8080
      host: 127.0.0.1
      state: started
      timeout: 30

  - name: The endpoint is healthy
    ansible.builtin.uri:
      url: "http://127.0.0.1:8080/healthz"
      status_code: 200
      return_content: true
      timeout: 10
    register: health

  - name: The endpoint reports the version we deployed
    ansible.builtin.assert:
      that:
        - health.json.version == expected_version
      fail_msg: >-
        Endpoint reports {{ health.json.version | default('no version') }},
        expected {{ expected_version }}
      quiet: true

The last assertion is the one that closes the gap in the opening sentence. The service is running, it is bound, it returns 200 — and it is running the previous release because the restart raced the symlink swap. Only a check that compares what the endpoint reports against what was deployed catches that.

assert, fail, failed_when

Three mechanisms, and choosing correctly is mostly about who reads the message.

UseWhenWhat it gives you
assertTesting a condition that should holdthat, fail_msg, success_msg, quiet
failEnding deliberately, usually inside a whenA single message, full control of the condition
failed_whenReinterpreting another task’s resultTurns a task’s success into a failure, or the reverse

assert is the default for validation because fail_msg puts the diagnosis in the failure. quiet: true suppresses the per-assertion success output, which on a 300-host run is the difference between a readable result and forty screens.

The failed_when trap

This is the defect the curriculum flags for this part, and it is worth seeing executed because the reasoning that produces it is entirely sensible.

A compliance author wants a task to fail if any of several conditions indicates a problem, and writes them as a list — the way assert takes a list in that, where a list means “all of these must hold”.

Read-only / Safea list given to failed_when is joined with implicit and
$ ansible-playbook failed-when.yml
TASK [Probe A - one condition true, one false] *********************************
ok: [localhost] => {
  "msg": "compliance probe"
}

TASK [Probe B - both conditions true] ******************************************
[ERROR]: Task failed: Action failed: A 'failed_when' expression evaluated to 'True'.
fatal: [localhost]: FAILED! => {
  "msg": "compliance probe"
}
...ignoring

TASK [Report] ******************************************************************
ok: [localhost] => {
  "msg": "A.failed=False B.failed=True"
}

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=1

A.failed=False. One of its two conditions was true and the task passed.

Now put that in a compliance role:

# This check can essentially never fire.
- name: Assess the host against the baseline
  ansible.builtin.command:
    argv: [/usr/local/bin/assess-baseline]
  register: assessment
  changed_when: false
  failed_when:
    - assessment.rc != 0
    - "'FAIL' in assessment.stdout"
    - assessment.stdout_lines | length > 3
    - "'CRITICAL' in assessment.stdout"

The author’s intent was “fail if any of these indicates a problem”. The semantics are “fail only if all four are simultaneously true”. Add a fifth condition and it becomes even less likely to fire. Every host reports compliant.

The fix is to write the disjunction explicitly:

Read-only / Safean or, written as an or
- name: Assess the host against the baseline
ansible.builtin.command:
  argv: [/usr/local/bin/assess-baseline]
register: assessment
changed_when: false
check_mode: false
failed_when: >-
  assessment.rc != 0
  or 'FAIL' in assessment.stdout
  or 'CRITICAL' in assessment.stdout

There is a stronger version of the same advice: prefer assert for compliance checks and reserve failed_when for reinterpreting a task’s result. assert takes a list in that where “all must hold” is the natural reading, so writing each requirement as a positive assertion puts the semantics and the intent in the same direction.

- name: The baseline assessment passed
  ansible.builtin.assert:
    that:
      - assessment.rc == 0
      - "'FAIL' not in assessment.stdout"
      - "'CRITICAL' not in assessment.stdout"
    fail_msg: "Baseline assessment failed on {{ inventory_hostname }}"
    quiet: true

Same three requirements, expressed as things that must be true rather than things that must all be wrong, and now the conjunction is what the author wants.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A compliance task uses failed_when with a YAML list of four failure symptoms: a non-zero return code, FAIL in stdout, more than three output lines, and CRITICAL in stdout. Every host reports compliant. Why?

  2. Q2. A validation play checks that the package version is correct, the service is running, the port is listening and the endpoint returns 200. Which failures would still get past it? Select all that apply.

  3. Q3. Writing compliance requirements as positive assertions in assert rather than as failure symptoms in failed_when aligns the intent with the semantics, because a list under that is meant to be read as all of these must hold.

  4. Q4. Which validation check is closest to detecting the specific failure where template succeeded, systemctl restart succeeded, and the application returns 500 because of a typo in the database host?

Passing score: 75%. Answers are checked in this browser.