Objective
By the end of this lab you will have run a real patch against five hosts with a one-host canary, watched it stop when the third host failed, and produced — from evidence rather than from the recap — a classification of all five hosts into patched, failed, and never attempted. You will then resume the rollout without re-patching anything that already succeeded.
Architecture
Five managed nodes patched in batches of 1, 2 and 2. One of them has a deliberately induced fault.
controller
│ serial: [1, 2, 2]
├── batch 1: node1 canary
├── batch 2: node2, node3 node3 fails here
└── batch 3: node4, node5 never attempted
Requirements
- A controller with
ansible-core2.21.x. - Five managed nodes with a real package manager and systemd as PID 1.
This lab installs and upgrades packages and restarts services. A
container cannot do the service half honestly, and a container’s package
database is not the thing you are trying to learn about.
B-nested, real VMs, no simulation path. - SSH key access and
becomeon each node. - Network access to a package repository, or a local mirror.
- No out-of-band access requirement: this lab does not touch SSH, the firewall, networking or the kernel. It can leave a package upgraded and a service restarted, which Cleanup addresses.
Scenario
Your change record says: patch five application servers, one first, then the rest in pairs, and stop if anything goes wrong.
Something goes wrong on the third host. The change board’s question the next morning is not “why did it fail” — that is easy. It is “which of the five servers are now running the new package version, and which are not”, and the answer must be an evidence trail, not a recollection.
Tasks
Task 1: Capture the pre-patch state
WORKDIR="$HOME/ansible-canary-lab"
mkdir -p "$WORKDIR/reports"
cd "$WORKDIR"
inventory.yml:
appservers:
hosts:
node1: {ansible_host: 192.0.2.11}
node2: {ansible_host: 192.0.2.12}
node3: {ansible_host: 192.0.2.13}
node4: {ansible_host: 192.0.2.14}
node5: {ansible_host: 192.0.2.15}
vars:
ansible_user: operator
patch_package: rsync
# capture.yml
- name: Record the installed version on every host
hosts: appservers
become: true
gather_facts: true
tasks:
- name: Read the package database
ansible.builtin.package_facts:
- name: Record the version of the package under patch
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
captured: {{ ansible_date_time.iso8601 }}
package: {{ patch_package }}
installed: {{ ansible_facts.packages[patch_package][0].version
| default('NOT INSTALLED') }}
kernel: {{ ansible_facts.kernel }}
dest: "{{ playbook_dir }}/reports/pre-patch-{{ inventory_hostname }}.yml"
mode: '0644'
delegate_to: localhost
become: false
$ ansible-playbook -i inventory.yml capture.yml && grep -h installed reports/pre-patch-*.ymlEvery host must have a recorded version before you continue. A host with
NOT INSTALLED will behave differently in the patch play and is worth
noticing now rather than mid-run.
Task 2: Write the patch play
# patch.yml
- name: Patch the application servers, canary first
hosts: appservers
become: true
gather_facts: true
serial: [1, 2, 2]
max_fail_percentage: 0
vars:
patch_package: rsync
tasks:
- name: Announce the batch
ansible.builtin.debug:
msg: "batch: {{ ansible_play_batch | join(', ') }}"
run_once: true
- name: Record the version before this host is patched
ansible.builtin.package_facts:
- name: Save the pre-task version as a fact
ansible.builtin.set_fact:
version_before: >-
{{ ansible_facts.packages[patch_package][0].version
| default('absent') }}
- name: Apply the update
ansible.builtin.package:
name: "{{ patch_package }}"
state: latest
register: patch_result
- name: Re-read the package database
ansible.builtin.package_facts:
- name: Record what this host now runs
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
patched_at: {{ ansible_date_time.iso8601 }}
package: {{ patch_package }}
version_before: {{ version_before }}
version_after: {{ ansible_facts.packages[patch_package][0].version
| default('absent') }}
changed: {{ patch_result.changed }}
dest: "{{ playbook_dir }}/reports/patched-{{ inventory_hostname }}.yml"
mode: '0644'
delegate_to: localhost
become: false
- name: Verify the package is functional after the update
ansible.builtin.command: "{{ patch_package }} --version"
register: post_check
changed_when: false
- name: Assert the post-patch check succeeded
ansible.builtin.assert:
that: post_check.rc == 0
fail_msg: "{{ patch_package }} is not runnable after the update"
success_msg: "{{ inventory_hostname }} patched and verified"
Task 3: Induce the failure on node3
# induce.yml
- name: Hold the package manager lock on node3
hosts: node3
become: true
gather_facts: false
tasks:
- name: Take the dpkg lock in the background
ansible.builtin.shell: |
set -euo pipefail
nohup flock /var/lib/dpkg/lock-frontend sleep 1800 >/dev/null 2>&1 &
echo $!
args:
executable: /bin/bash
register: holder
changed_when: true
- name: Record the holder PID so cleanup can find it
ansible.builtin.copy:
content: "{{ holder.stdout | trim }}\n"
dest: "{{ playbook_dir }}/reports/lock-holder-pid.txt"
mode: '0644'
delegate_to: localhost
become: false
$ ansible-playbook -i inventory.yml induce.yml && cat reports/lock-holder-pid.txtTask 4: Run the patch and watch it stop
$ ansible-playbook -i inventory.yml patch.ymlTASK [Announce the batch] ******************************************************
ok: [node1] => {"msg": "batch: node1"}
...
TASK [Assert the post-patch check succeeded] ***********************************
ok: [node1] => {"msg": "node1 patched and verified"}
PLAY [Patch the application servers, canary first] *****************************
TASK [Announce the batch] ******************************************************
ok: [node2] => {"msg": "batch: node2, node3"}
TASK [Apply the update] ********************************************************
ok: [node2]
fatal: [node3]: FAILED! => {"changed": false, "msg": "Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 4471 (flock)"}
NO MORE HOSTS LEFT *************************************************************
PLAY RECAP *********************************************************************
node1 : ok=8 changed=1 unreachable=0 failed=0
node2 : ok=8 changed=1 unreachable=0 failed=0
node3 : ok=3 changed=0 unreachable=0 failed=1Illustrative output
Record the exit code — it is part of the change record:
ansible-playbook -i inventory.yml patch.yml > reports/patch-run.txt 2>&1
echo "exit=$?" | tee -a reports/patch-run.txt
A play aborted by max_fail_percentage exits 2, the same as any task
failure. There is no distinct exit code for “stopped by policy”, which is
worth knowing when a wrapper script is deciding what to do next.
Task 5: Classify all five hosts
The recap has three lines. The inventory has five hosts. Produce the classification the change board needs:
cd "$HOME/ansible-canary-lab"
# Hosts the run reached
sed -n '/PLAY RECAP/,$p' reports/patch-run.txt \
| grep -oE '^node[0-9]+' | sort -u > reports/attempted.txt
# Hosts that failed, read from the recap lines only
sed -n '/PLAY RECAP/,$p' reports/patch-run.txt \
| awk '/failed=[1-9]/ {print $1}' | sort -u > reports/failed.txt
# Hosts the inventory contains
ansible -i inventory.yml appservers --list-hosts \
| tail -n +2 | tr -d ' ' | sort > reports/all.txt
echo "=== NEVER ATTEMPTED ==="
comm -13 reports/attempted.txt reports/all.txt
$ comm -13 reports/attempted.txt reports/all.txtnode4
node5Illustrative output
Then confirm each classification against the hosts themselves, because the run output is a claim and the package database is the fact:
# classify.yml
- name: Establish the true post-abort state
hosts: appservers
become: true
gather_facts: true
tasks:
- name: Read the package database
ansible.builtin.package_facts:
- name: Load the pre-patch record
ansible.builtin.include_vars:
file: "{{ playbook_dir }}/reports/pre-patch-{{ inventory_hostname }}.yml"
name: pre
delegate_to: localhost
become: false
- name: Report the classification
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}:
before={{ pre.installed }}
now={{ ansible_facts.packages[patch_package][0].version
| default('absent') }}
state={{ 'PATCHED' if ansible_facts.packages[patch_package][0].version
!= pre.installed else 'UNPATCHED' }}
Task 6: Write the change record entry
cd "$HOME/ansible-canary-lab"
cat > reports/change-record.md <<'MD'
# Patch run — CHG-XXXX
Policy: serial [1, 2, 2], max_fail_percentage 0
Package: rsync
| Host | Before | After | State | Evidence |
|-------|----------|----------|----------------|----------|
| node1 | | | PATCHED | reports/patched-node1.yml |
| node2 | | | PATCHED | reports/patched-node2.yml |
| node3 | | | FAILED | reports/patch-run.txt |
| node4 | | | NOT ATTEMPTED | absent from recap |
| node5 | | | NOT ATTEMPTED | absent from recap |
Abort cause:
Remediation:
Resume plan:
MD
ls reports/
Fill it in from the artefacts. Every row must cite a file.
Task 7: Resume without re-patching
Fix the underlying cause first — release the lock — then resume against only the hosts that still need it:
cd "$HOME/ansible-canary-lab"
# Build the limit file from the classification, not from memory
{
echo "# resume after CHG-XXXX abort, generated $(date -Is)"
echo "# excludes hosts confirmed PATCHED"
cat reports/failed.txt
comm -13 reports/attempted.txt reports/all.txt
} > reports/resume.limit
cat reports/resume.limit
Task 8: Release the induced fault
# release.yml
- name: Release the package manager lock on node3
hosts: node3
become: true
gather_facts: false
tasks:
- name: Read the recorded holder PID
ansible.builtin.slurp:
src: "{{ playbook_dir }}/reports/lock-holder-pid.txt"
register: pidfile
delegate_to: localhost
become: false
- name: Kill the recorded holder
ansible.builtin.command: "kill {{ (pidfile.content | b64decode) | trim }}"
register: killed
failed_when: false
changed_when: killed.rc == 0
- name: Find any remaining holder, in case the PID was stale
ansible.builtin.shell: |
fuser -k /var/lib/dpkg/lock-frontend 2>/dev/null || true
args:
executable: /bin/bash
changed_when: false
- name: Confirm the lock is free
ansible.builtin.command: apt-get check
register: aptcheck
changed_when: false
$ ansible-playbook -i inventory.yml release.ymlThen run the resume and confirm all five hosts reach the new version.
Validation
reports/pre-patch-node{1..5}.ymlexist and record a version for each host, written before any patching.- The patch run’s recap contains exactly three host lines, and exits 2.
comm -13 reports/attempted.txt reports/all.txtnamesnode4andnode5.classify.ymlreportsPATCHEDfor node1 and node2 andUNPATCHEDfor node3, node4 and node5.node3’s failure message namesCould not get lockand a PID.- After
release.yml,apt-get checkon node3 exits 0. - The resume run with
--limit @reports/resume.limittargets exactly three hosts, and after it every host reports the same version. reports/change-record.mdhas an evidence file cited for every row.
Expected Outcome
ansible-canary-lab/
├── capture.yml, patch.yml, induce.yml, release.yml, classify.yml
├── inventory.yml
└── reports/
├── all.txt, attempted.txt, failed.txt
├── change-record.md
├── lock-holder-pid.txt
├── patch-run.txt
├── patched-node{1,2}.yml
├── pre-patch-node{1..5}.yml
└── resume.limit
Five hosts at the same package version, an evidence trail showing the order in which they got there, and a change record that distinguishes failed from never-attempted without relying on anybody’s memory.
Troubleshooting
The canary batch is not honoured. serial: [1, 2, 2] must be a YAML
list. Written as the string "1, 2, 2" it is not parsed as one and the
whole play runs as a single batch — with no error. Confirm with the
Announce the batch task’s output before trusting the policy.
max_fail_percentage: 0 did not abort. It has no effect without
serial. Also confirm it is a number, not a quoted string.
package: state: latest reports changed on every host, every run. That
is latest doing its job: it compares against the repository. For a patch
play that is what you want; for a convergence play it is not, and the two
should not be the same play.
The lock injection does not cause a failure. flock may not be present,
or the path differs on a non-Debian host. On RHEL family the equivalent
lock is /var/run/dnf.pid or a dnf transaction; use
dnf -y install in a background loop instead, or induce the failure with
a dpkg --set-selections hold on the package.
node4 and node5 appear in the recap as ok=0. Then the play did
attempt them, which means the abort did not happen where you thought. Check
that max_fail_percentage was actually applied — this is the failure mode
the Announce the batch task exists to make visible.
A host reports the new version but the binary does not run. The
half-configured dpkg case. dpkg --configure -a on that host, then
re-run the verification task. This is the state that makes “check the
version” an insufficient classification.
Cleanup
This lab upgraded a package on up to five hosts, held a package manager lock, and left report files. Restoring the package version is the part that may not be fully possible, and the cleanup says so rather than pretending.
Step 1. Release any remaining lock, unconditionally:
cd "$HOME/ansible-canary-lab"
ansible-playbook -i inventory.yml release.yml
ansible -i inventory.yml appservers -b -m command -a 'apt-get check'
Step 2. Decide what “restore” means for the package. Read the capture:
grep -h -A1 'package:' reports/pre-patch-*.yml
Where a snapshot exists, rolling the VM back is the correct restoration and is preferable to any package surgery.
Step 3. Confirm no lock holder survives on any host:
ansible -i inventory.yml appservers -b -m shell \
-a 'fuser -v /var/lib/dpkg/lock-frontend 2>&1 || echo "lock free"'
ansible -i inventory.yml appservers -b -m shell \
-a 'pgrep -a "flock" || echo "no flock processes"'
Step 4. Keep the change record and remove the working directory:
mkdir -p "$HOME/ansible-lab-deliverables/canary"
cp -a reports/change-record.md reports/resume.limit patch.yml \
"$HOME/ansible-lab-deliverables/canary/"
rm -rf "$HOME/ansible-canary-lab"
What You Learned
- A canary batch is
serial: [1, ...]and nothing else is required — but it must be a YAML list, and a string form silently degrades to one batch. max_fail_percentage: 0means any failure aborts, because the comparison is strictly greater than zero.- The recap lists three hosts for a five-host inventory. Never
attempted is not a state the recap can express, and
commagainst--list-hostsis how you recover it. - Write the per-host record before the verification task. If verification is what fails, you still need to know the host was patched.
- A version match is not a health check. A half-configured package reports the new version and does not run, which is why the play executes the binary.
- Resume is a new change with a new blast radius, its own limit file generated from the classification, and its own canary.
- Some cleanups cannot fully restore. A package downgrade depends on the old version still existing; forcing one is worse than documenting that you did not.