Objective
By the end of this lab you will have run a change against ten hosts where two fail a task and three lose network connectivity mid-run, and you will have produced four classified lists — complete, failed, unreachable, never attempted — each backed by evidence gathered from the hosts rather than from the run output. You will then make and justify a different decision for each class.
Architecture
Ten managed nodes. Two carry a fault that makes a task fail; three have their inbound SSH blocked part-way through the run.
controller ──ssh──▶ node01 .. node10
node03, node07 task fails (a missing dependency)
node05,06,08 SSH blocked mid-run (nftables rule added)
the rest complete normally
Requirements
- A controller with
ansible-core2.21.x. - Ten managed VMs with systemd as PID 1 and a working firewall. The
connection-loss half of this lab needs genuine network interruption and
real service state; neither is honest in a container.
B-nestedonly. - SSH key access and
becomeon all ten. - Out-of-band access to node05, node06 and node08. Task 4 blocks inbound SSH on those three. The rule is added by a self-cancelling mechanism with a timeout, and Cleanup removes it, but a mistake here locks you out over the network and the recovery is a console.
nftableson the managed nodes, or theiptablesequivalent adapted.
Scenario
A configuration change ran against ten hosts overnight. The recap in the
job log shows a mixture of ok, failed and unreachable, and the
engineer who scheduled it is on leave.
You have to answer three questions before anything else can happen: which hosts got the change, which hosts are in an unknown state, and what to do about each. The recap is where you start and it is not where you finish.
Tasks
Task 1: Set up and capture
WORKDIR="$HOME/ansible-triage-lab"
mkdir -p "$WORKDIR/reports"
cd "$WORKDIR"
inventory.yml:
fleet:
hosts:
node[01:10]:
vars:
ansible_user: operator
Give each host an ansible_host in host_vars/, or use DNS. Then:
# setup.yml
- name: Install the helper the change depends on
hosts: fleet
become: true
gather_facts: false
tasks:
- name: Install the config validator
ansible.builtin.copy:
content: |
#!/bin/bash
# Validates the rendered config. Exits non-zero if it is wrong.
grep -q '^setting=' "$1" || exit 1
exit 0
dest: /usr/local/bin/validate-appconfig
mode: '0755'
- name: Record the pre-change state
ansible.builtin.stat:
path: /etc/appconfig
register: pre
- name: Save the capture
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
appconfig_existed: {{ pre.stat.exists }}
dest: "{{ playbook_dir }}/reports/pre-{{ inventory_hostname }}.yml"
mode: '0644'
delegate_to: localhost
become: false
Task 2: The change play
# change.yml
- name: Apply the configuration change
hosts: fleet
become: true
gather_facts: false
tasks:
- name: Render the configuration
ansible.builtin.copy:
content: |
setting=new-value
applied_by=ansible
dest: /etc/appconfig
mode: '0644'
validate: '/usr/local/bin/validate-appconfig %s'
- name: Wait, so there is time to interrupt the run
ansible.builtin.wait_for:
timeout: 45
delegate_to: localhost
become: false
run_once: true
- name: Register the change with the local audit log
ansible.builtin.lineinfile:
path: /var/log/appconfig-changes.log
line: "{{ ansible_date_time.iso8601 | default('unknown') }} applied"
create: true
mode: '0644'
- name: Verify the configuration is readable and correct
ansible.builtin.command: /usr/local/bin/validate-appconfig /etc/appconfig
changed_when: false
Task 3: Induce the task failure on node03 and node07
cd "$HOME/ansible-triage-lab"
ansible -i inventory.yml node03,node07 -b -m file \
-a 'path=/usr/local/bin/validate-appconfig state=absent'
Now validate: on the copy task has no validator, and the task fails
before writing anything. That is a clean failure — the host is
unchanged — and recognising that class is half the triage.
Task 4: Block SSH on three hosts mid-run
# block-ssh.yml
- name: Block inbound SSH, with an automatic release
hosts: node05,node06,node08
become: true
gather_facts: false
tasks:
- name: Confirm the at daemon is running before we depend on it
ansible.builtin.command: systemctl is-active atd
register: atd
changed_when: false
failed_when: atd.stdout != 'active'
- name: Schedule the release FIRST, before installing the block
ansible.builtin.shell: |
set -euo pipefail
echo 'nft delete table inet triagelab' | at now + 15 minutes
args:
executable: /bin/bash
changed_when: true
- name: Install the block
ansible.builtin.shell: |
set -euo pipefail
nft add table inet triagelab
nft add chain inet triagelab input '{ type filter hook input priority -10 ; }'
nft add rule inet triagelab input tcp dport 22 ct state new drop
args:
executable: /bin/bash
changed_when: true
Run the change and the block together, so the block lands while the change is in its 45-second wait:
cd "$HOME/ansible-triage-lab"
ansible-playbook -i inventory.yml change.yml > reports/change-run.txt 2>&1 &
CHANGE_PID=$!
sleep 10
ansible-playbook -i inventory.yml block-ssh.yml
wait "$CHANGE_PID"
echo "change run exit: $?"
tail -20 reports/change-run.txt
Task 5: Read the recap, and know what it does not say
$ sed -n '/PLAY RECAP/,$p' reports/change-run.txtPLAY RECAP *********************************************************************
node01 : ok=4 changed=2 unreachable=0 failed=0
node02 : ok=4 changed=2 unreachable=0 failed=0
node03 : ok=0 changed=0 unreachable=0 failed=1
node04 : ok=4 changed=2 unreachable=0 failed=0
node05 : ok=1 changed=1 unreachable=1 failed=0
node06 : ok=1 changed=1 unreachable=1 failed=0
node07 : ok=0 changed=0 unreachable=0 failed=1
node08 : ok=1 changed=1 unreachable=1 failed=0
node09 : ok=4 changed=2 unreachable=0 failed=0
node10 : ok=4 changed=2 unreachable=0 failed=0Illustrative output
Read the three classes carefully, because they mean different things.
Task 6: Build the four classified lists
The recap gives you three classes. Extract them mechanically:
cd "$HOME/ansible-triage-lab"
R=reports/change-run.txt
sed -n '/PLAY RECAP/,$p' "$R" | awk '/^node/ && /failed=0/ && /unreachable=0/ {print $1}' \
| sort > reports/class-complete.txt
sed -n '/PLAY RECAP/,$p' "$R" | awk '/^node/ && /failed=[1-9]/ {print $1}' \
| sort > reports/class-failed.txt
sed -n '/PLAY RECAP/,$p' "$R" | awk '/^node/ && /unreachable=[1-9]/ {print $1}' \
| sort > reports/class-unreachable.txt
ansible -i inventory.yml fleet --list-hosts | tail -n +2 | tr -d ' ' | sort \
> reports/class-all.txt
sed -n '/PLAY RECAP/,$p' "$R" | grep -oE '^node[0-9]+' | sort -u \
> reports/class-attempted.txt
comm -13 reports/class-attempted.txt reports/class-all.txt \
> reports/class-never-attempted.txt
wc -l reports/class-*.txt
Task 7: Establish the truth from the hosts
Verify every host independently, including the ones the recap says are fine. This is the step that separates triage from guesswork.
# establish-truth.yml
- name: Establish the actual state of every host
hosts: fleet
become: true
gather_facts: false
ignore_unreachable: true
tasks:
- name: Read the configuration file
ansible.builtin.slurp:
src: /etc/appconfig
register: conf
failed_when: false
- name: Read the audit log
ansible.builtin.slurp:
src: /var/log/appconfig-changes.log
register: auditlog
failed_when: false
- name: Report the true state
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
config={{ 'PRESENT' if conf.content is defined else 'ABSENT' }}
audit={{ 'LOGGED' if auditlog.content is defined else 'MISSING' }}
when: not (ansible_host_unreachable | default(false))
- name: Record it
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
reachable: {{ not (ansible_host_unreachable | default(false)) }}
config_present: {{ conf.content is defined }}
audit_logged: {{ auditlog.content is defined }}
dest: "{{ playbook_dir }}/reports/truth-{{ inventory_hostname }}.yml"
mode: '0644'
delegate_to: localhost
become: false
Run it while the three hosts are still blocked, and then again after the
at job releases them, and compare:
ansible-playbook -i inventory.yml establish-truth.yml
cat reports/truth-node05.yml
$ cat reports/truth-node05.ymlhost: node05
reachable: true
config_present: true
audit_logged: falseIllustrative output
That is the partial-application state, established as a fact. node05 has
the new configuration and no record that it was applied — which is exactly
the combination that makes a subsequent audit report it as an unmanaged
change.
Task 8: Decide, per class
Write reports/triage.md. Four classes, four decisions, each with a
justification.
| Class | Hosts | State | Decision |
|---|---|---|---|
| Complete | node01,02,04,09,10 | config + audit line | verify and close |
| Failed clean | node03, node07 | unchanged | retry after fixing the cause |
| Unreachable, partially applied | node05, 06, 08 | config, no audit line | investigate then re-run |
| Never attempted | — | untouched | retry in the resume run |
Task 9: Resume without re-running what completed
cd "$HOME/ansible-triage-lab"
# Fix the cause on the failed hosts
ansible-playbook -i inventory.yml setup.yml --limit node03,node07
# Build the resume limit from the classification
{
echo "# resume after partial failure, $(date -Is)"
echo "# excludes hosts verified complete"
cat reports/class-failed.txt
cat reports/class-unreachable.txt
cat reports/class-never-attempted.txt
} | sort -u > reports/resume.limit
ansible-playbook -i inventory.yml change.yml \
--limit @reports/resume.limit --list-hosts
Check the list, then run it, then re-run establish-truth.yml and confirm
all ten hosts report config_present: true and audit_logged: true.
Validation
- The recap shows
failed=1for node03 and node07 withchanged=0, andunreachable=1withchanged=1for node05, node06 and node08. reports/class-*.txtpartition the ten hosts with no host in two lists and none missing.establish-truth.ymlcompletes on all ten hosts even while three are unreachable, because ofignore_unreachable: true.reports/truth-node05.ymlreportsconfig_present: trueandaudit_logged: falseonce the host is reachable again.reports/truth-node03.ymlreportsconfig_present: false— the clean failure.reports/triage.mdassigns a decision and a justification to each of the four classes.- After the resume, all ten
truth-*.ymlreport bothtrue. nft list ruleseton node05, node06 and node08 shows notriagelabtable.
Expected Outcome
ansible-triage-lab/
├── block-ssh.yml, change.yml, establish-truth.yml, setup.yml
├── inventory.yml
└── reports/
├── change-run.txt
├── class-{all,attempted,complete,failed,never-attempted,unreachable}.txt
├── pre-node{01..10}.yml
├── resume.limit
├── triage.md
└── truth-node{01..10}.yml
Ten hosts in a verified, identical state, and a written triage that distinguishes four classes and justifies a decision for each.
Troubleshooting
Every host reports unreachable. The block was applied to the wrong
group, or your controller’s own connection to the fleet went through one of
the blocked hosts as a bastion. Check ansible_ssh_common_args in the
inventory.
The block does not expire. atd was not running when the at job was
submitted, so the job was queued and never scheduled, or it was never
accepted at all. The task asserting systemctl is-active atd exists to
prevent this — if you skipped it, go to the console.
ignore_unreachable does not seem to work. It must be on the play or
the task. It changes what happens after a host becomes unreachable; it
does not make the connection succeed, so tasks against that host still
produce no data. That is expected — the value is that the play completes.
ansible_host_unreachable is undefined. It is set only on hosts that
actually went unreachable. | default(false) is required, as used above.
slurp fails on a file that exists. slurp base64-encodes the whole
file into a fact, so it fails on large files and on files the become-user
cannot read. For a large log, use command: tail -5 with
changed_when: false instead.
A host reports config_present: true but the content is wrong. The
truth play checks presence, not content. Extend it — conf.content | b64decode and a regex_search for the expected value — because “the file
exists” is a weaker claim than most triage needs.
The recap has fewer than ten lines. Some hosts were never attempted,
which on a play with no serial means the play aborted early. Check for
any_errors_fatal or a max_fail_percentage you did not intend.
Cleanup
This lab blocked SSH on three hosts, removed a helper from two, and wrote a configuration file on up to ten. All of it must be reversed, and the firewall rule is the one that must be reversed first.
Step 1. Remove the firewall block, and verify. Do this before anything
else, and do not assume the at job ran:
# unblock-ssh.yml
- name: Remove the triage lab firewall block
hosts: node05,node06,node08
become: true
gather_facts: false
tasks:
- name: Remove the table if it exists
ansible.builtin.shell: |
set -uo pipefail
nft list table inet triagelab >/dev/null 2>&1 && nft delete table inet triagelab
exit 0
args:
executable: /bin/bash
changed_when: true
- name: Confirm the table is gone
ansible.builtin.shell: |
set -uo pipefail
nft list table inet triagelab >/dev/null 2>&1 && echo PRESENT || echo ABSENT
args:
executable: /bin/bash
register: tbl
changed_when: false
- name: Assert the block is removed
ansible.builtin.assert:
that: tbl.stdout | trim == 'ABSENT'
fail_msg: >-
The triagelab table is still present on {{ inventory_hostname }}.
Remove it from the console before this session ends.
- name: Remove any queued at jobs the lab created
ansible.builtin.shell: |
set -uo pipefail
for j in $(atq | awk '{print $1}'); do
at -c "$j" 2>/dev/null | grep -q 'triagelab' && atrm "$j"
done
exit 0
args:
executable: /bin/bash
changed_when: true
Step 2. Restore the helper on the two failed hosts and remove the lab’s files everywhere:
# cleanup.yml
- name: Remove everything the lab created
hosts: fleet
become: true
gather_facts: false
tasks:
- name: Load the pre-lab capture
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/reports/pre-{{ inventory_hostname }}.yml"
name: pre
delegate_to: localhost
become: false
- name: Refuse to continue without a capture
ansible.builtin.assert:
that: pre.host is defined
fail_msg: "No pre-lab capture for {{ inventory_hostname }}."
- name: Remove the config file the lab created
ansible.builtin.file:
path: /etc/appconfig
state: absent
when: not (pre.appconfig_existed | bool)
- name: Remove the validator and the audit log
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /usr/local/bin/validate-appconfig
- /var/log/appconfig-changes.log
Step 3. Verify the fleet is clean and reachable:
cd "$HOME/ansible-triage-lab"
ansible -i inventory.yml fleet -m ping
ansible -i inventory.yml node05,node06,node08 -b -m shell \
-a 'nft list ruleset | grep -c triagelab || echo 0'
ansible -i inventory.yml fleet -b -m stat -a 'path=/etc/appconfig' | grep -c '"exists": true' || true
All ten must respond to ping, no host may report a triagelab rule, and
atq must be empty of lab jobs.
Step 4. Keep the triage write-up and remove the working directory:
mkdir -p "$HOME/ansible-lab-deliverables/triage"
cp -a reports/triage.md establish-truth.yml \
"$HOME/ansible-lab-deliverables/triage/"
rm -rf "$HOME/ansible-triage-lab"
What You Learned
unreachableandfailedare produced by different layers and mean opposite things operationally. A failed host usually changed nothing; an unreachable host may have changed something and then gone silent.ok=1 changed=1 unreachable=1is the line to read first. It is a host in a partial state, and it is the only class you cannot reason about without going and looking.- The recap expresses three classes and there are four. Never-attempted
hosts have no recap entry, and
commagainst--list-hostsis the only way to list them. ignore_unreachable: truebelongs on diagnostic plays and nowhere else. It is what lets a triage play report on the hosts it cannot reach.class-complete.txtis a claim. You verified all ten hosts from the hosts themselves, including the ones the recap said were fine.- Four responses, and retry is not the default. Retry the clean failures, investigate the simultaneous ones, quarantine what you cannot make known, and roll back only when a mixed state is worse than no change.
- Install the undo before the block. The
atjob that removed the firewall rule was scheduled before the rule existed, which is the only ordering that is safe over a network.