Reported symptoms
A security agent was rolled out to 200 hosts. The rollout run was green:
200 hosts, failed=0, no errors, change record closed.
Three weeks later an audit queries the agent management console and finds 200 hosts missing. Not misconfigured, not stale - absent. The agent has never been installed on any of them.
Everything about the automation appears correct:
- The playbook names the right package.
- Running the install task on its own, by hand, produces a clear failure that explains itself immediately.
- The nightly converge has run every night since and reported success every time.
- No dashboard, alert or pipeline has ever flagged anything.
The only oddity, once somebody goes back to the log, is that the install
task reports ok rather than changed - which on an idempotent task is
what you would expect on a second run and is entirely unremarkable on a
first.
Evidence provided
$ grep -A3 'Install the security agent' logs/rollout.log | head -6TASK [Install the security agent] **********************************************
fatal: [app001]: FAILED! => {"changed": false, "msg": "Failed to update apt cache: unauthorized"}
...ignoring$ tail -3 logs/rollout.logPLAY RECAP *********************************************************************
app001 : ok=12 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=1
app002 : ok=12 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=1$ grep -n -A6 'Install the security agent' roles/security/tasks/main.yml21:- name: Install the security agent
22: ansible.builtin.package:
23: name: vendor-agent
24: state: present
25: ignore_errors: true$ git log -1 --format='%h %ad %s' --date=short -L21,25:roles/security/tasks/main.yml | head -2b4419c7 2025-12-14 security: tolerate a flaky host during the December change$ ansible app001 -i inventory -b -m ansible.builtin.command -a 'apt-get update' | tail -2E: Failed to fetch https://repo.example.com/vendor/dists/stable/InRelease 401 Unauthorized
E: Some index files failed to download.$ grep -c 'assert\|command:.*vendor-agent\|service_facts' roles/security/tasks/main.yml0Work the evidence before reading on
The failure is in the log. It has been in the log every night for three
weeks. The run still ends failed=0.
- Read the three lines of the task output in order. What is the third one, and what does it do to the second?
- Look at the play recap columns. Which one is non-zero, and where in the line is it?
- The role installs something. What does it do afterwards to establish that the thing is there?
Before continuing: what did the commit eight months ago intend to tolerate, and what does the line actually tolerate?
Root cause
1. ignore_errors discards every error, not the one you had in mind
ignore_errors: true means the task cannot fail the play. Whatever goes
wrong - a transient network blip, an expired credential, a missing
package, a full disk, a repository that no longer exists - the result is
downgraded to ignored and the play continues.
The commit that added it was addressing a real problem: one host failed transiently during an unrelated change in December, and the line made the run go green that afternoon. It was never removed, because nothing ever went wrong afterwards - or rather, nothing ever appeared to.
When the vendor repository credential expired three weeks ago, the task started failing on every host. The failure is not transient, it is not host-specific, and it is exactly the kind of thing a rollout must stop for. It was ignored, along with everything else.
2. The signals exist and nothing consumes them
Ansible does report this. The task output ends with ...ignoring, and
the play recap carries an ignored=1 column.
Both are easy to miss for structural reasons rather than careless ones.
The ...ignoring line is one line among hundreds and contains no word
that anybody greps for. The ignored column is the last field of a
recap line that begins with the number everybody looks at, which is
failed=0.
Pipelines consume the exit code, which is 0. Dashboards count failed hosts, which is 0. The one number that is not zero is in neither.
3. Nothing asserted the outcome
The role installs the agent and then moves on. There is no task afterwards that checks whether the package is present, whether the service is running, or whether the agent has registered with its console.
Without that, the play asserts only that Ansible executed. The question the rollout actually needed answered - is the agent on this host - was never asked by anything until an auditor asked it three weeks later.
Resolution
- Raise the exposure first. 200 hosts have been without a security agent for three weeks while the compliance record said otherwise; that belongs with whoever owns the control, not in an Ansible ticket.
- Fix the underlying failure. The repository credential has expired, which is not an Ansible problem and would have been obvious on day one if the task had been allowed to fail.
- Remove
ignore_errors: truefrom the task. If a transient failure genuinely needs tolerating, express it with bounded retries or with afailed_whennaming the specific condition. - Add a verification task after the install that asserts the package is present and the service is running, so a future silent failure fails the run.
- Roll out to a canary host and confirm both the install and the verification behave, before the fleet.
- Deploy to the fleet and confirm from the agent management console that all 200 hosts have registered, rather than from the Ansible run.
- Audit the repository for every other
ignore_errors, and for the related suppressions -changed_when: falseon tasks that change things, and conditions added to skip failing hosts. - Add a CI rule that any new
ignore_errorsmust carry a comment naming the specific error being tolerated, so the next December commit has to explain itself.
Verification
- The verification task can fail. Remove the agent from a scratch host, break the repository credential, and run the play: it must report a failure. This is the check that would have caught the incident on day one and it had never existed.
- The play fails when the install fails. With
ignore_errorsremoved, confirm on that same scratch host that the run stops rather than continuing. - The agent is present on every host. Query the package and the service state across the fleet, and confirm the count is 200.
- The agents are reporting in. Confirm from the management console, which is independent of both the hosts and Ansible, and is the check the auditor performed.
- The recap is clean.
ignored=0across the fleet, and a grep of the run log forignoringreturns nothing. - The fleet is converged. Run the play once more and require
changed=0, so the state is stable rather than newly repaired. - The audit of other suppressions is complete, with each remaining one either removed or annotated with the specific error it tolerates and the reason it still applies.
Prevention
- Do not use
ignore_errors: trueon a task whose outcome matters. It trades every future failure for one past one. - Use the precise tool instead. Bounded
retrieswithuntilfor transience;failed_whenfor a named acceptable outcome;blockandrescuewhen there is something to do about the failure. - Assert the end state after anything that installs, configures or starts something:
- name: Confirm the security agent is installed and running
ansible.builtin.assert:
that:
- ansible_facts.packages['vendor-agent'] is defined
- ansible_facts.services['vendor-agent.service'].state == 'running'
fail_msg: "The security agent is not installed and running on {{ inventory_hostname }}."
- Alert on
ignoredandrescuedin the recap, not only onfailed. They are the columns that record failures the play decided to survive. - Fail CI on a new
ignore_errorswithout a comment naming the specific error and the reason it is acceptable. The comment is what makes the decision reviewable later. - Pair every suppression added under time pressure with a ticket to remove it, in the same commit. The urgency ends the same day; the line does not.
- Verify rollouts at an independent system where one exists. The agent console knew the truth for three weeks and nobody was comparing it against the automation.