AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates
Validation plays that fail loudly
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
The gap this course keeps returning to:
templatesucceeded.systemctl restartsucceeded. 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.
| Check | Mechanism | What it catches that the previous one does not |
|---|---|---|
| Package installed at the expected version | package_facts | A deploy that did not deploy |
| Config file correct | slurp or stat plus an assertion | A template that rendered wrong |
| Service running | service_facts | A unit that failed after starting |
| Port listening | wait_for | A service that is running but not bound |
| Endpoint healthy | uri | A process that is bound but broken |
| Certificate valid and not near expiry | x509_certificate_info | A 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.
- 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: trueThe 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.
| Use | When | What it gives you |
|---|---|---|
assert | Testing a condition that should hold | that, fail_msg, success_msg, quiet |
fail | Ending deliberately, usually inside a when | A single message, full control of the condition |
failed_when | Reinterpreting another task’s result | Turns 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”.
$ ansible-playbook failed-when.ymlTASK [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=1A.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:
- 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.stdoutThere 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
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?
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.
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.
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.