Skip to content

The Debug Task That Only Fails When Nothing Changes

Adityo Guni Waluyo

An Ansible dry run died on a harmless debug task. The skipped command tasks never filled their registered vars, and the trailing default filter never got a cha…

TL;DR

Running an Ansible playbook with --check can crash on a debug task even when the real tasks skip cleanly, because skipped tasks never register their variables. Filter chains run left to right, so last hits the undefined value before a trailing default can help. The fix is wrapping the value in default before later filters run.

I ran ansible-playbook ansible/playbooks/upgrade-9router.yml --check to preview a router upgrade. The run died on the last task. Not the package update, not the service restart. The harmless Report debug.

That was the confusing part. The tasks that actually touch the server passed clean in dry run. The one that only prints a line crashed.

My first guess was a typo in the Report task itself. I opened ansible/playbooks/upgrade-9router.yml and stared at the msg line. Looked fine.

Then I remembered what --check does. Check mode is "just a simulation" [1] where Ansible "runs without making changes" [1]. Modules that do not support it "report nothing and do nothing" [1]. My upstream command and shell tasks that register upgrade and health don't really run in check mode, so they get skipped. And check mode "will not generate output for tasks that use conditionals based on registered variables (results of prior tasks)" [1].

So the variables were never created. The failure was not in the skipped tasks. It detonated in the next task that tried to read them. That is why it only happens with --check.

`last` runs before `default` can help

This was the before line:

- name: Report
  ansible.builtin.debug:
    msg: "{{ inventory_hostname }} | {{ upgrade.stdout_lines | last | default('?') }} | http={{ health.stdout }}" 

It looks safe. The crash says otherwise.

Filter chains run left to right. upgrade.stdout_lines is undefined in check mode because the task that would register upgrade was skipped. last touches that undefined value first and throws before default('?') ever gets a chance. The trailing default is too late.

I have seen this pattern a few times now. Adding default at the end feels like a safety net, but it only guards the value right before it.

The docs define the default filter exactly like this: "If the value is undefined it will return the passed default value, otherwise the value of the variable." [2] And the official idiom for loops is to "use the |default filter to provide an empty iterator" [3] — right where the undefined value is, not at the end of the chain.

Small difference. Big effect.

Wrapping first, then filtering

The fix in commit 120691a was one line. Move default next to the value that might be missing, wrap it, then continue the chain:

- name: Report
  ansible.builtin.debug:
    msg: "{{ inventory_hostname }} | {{ (upgrade.stdout_lines | default(['(no output)'])) | last }} | http={{ health.stdout | default('-') }}" 

Now upgrade.stdout_lines defaults to ['(no output)'] before last runs. health.stdout defaults to '-' if health was never registered. No undefined value ever reaches the next filter.

It is a single pair of parentheses. Dry run finally finishes.

There are a couple of other tools from the same page that make this less painful. You can force a task to run or not run in check mode with check_mode: true or check_mode: false since version 2.2 [1]. You can branch with the ansible_check_mode magic variable in when or ignore_errors [1]. And --check --diff since 2.4 combined with --limit for one host at a time is a good way to preview without flooding the fleet [1].

I now treat this as a rule: every report or debug task that reads a registered variable must default it. (no output) and '-' are not cosmetics. They are what lets --check complete at all.

Since that one-line change, I run --check before every fleet upgrade. It takes seconds and it actually finishes.

Sources

  1. Ansible docs: Validating tasks, check mode and diff mode
  2. Ansible docs: ansible.builtin.default filter
  3. Ansible docs: Conditionals

Related articles