Skip to main content
RunBook Academy

AnsibleXL · Patch and Reboot ManagementPatch and Reboot Management

Proving a host came back, not just answered

Advanced⏱ ~27 min🧪 Lab requiredansible-playbook

What you'll learn

  • Distinguish reachability from readiness and from health, and validate each separately
  • Read systemctl is-system-running states and know which of them are failures
  • Verify mounts, services and cluster membership from facts rather than shell pipelines
  • Place validation so that its failure stops the batch instead of being recorded and ignored

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

Not yet marked complete on this device.

A host that answers SSH after a reboot has proven exactly one thing: sshd started.

That is a low bar and it is the bar most patch plays stop at. The host whose data volume failed to mount answers SSH. The host whose application did not start answers SSH. The host that came up in degraded state with a failed unit answers SSH. The host that rebooted into the old kernel because the bootloader default was never updated answers SSH, cheerfully, for months.

Validation is the step that turns “answered” into “working”, and it is the only thing standing between a rolling patch run and a rolling outage.

Three questions, in order

QuestionAnswered byWhat it proves
Is it reachable?wait_for_connection, or the reboot module’s test_commandthe transport works
Is it ready?systemctl is-system-running, mounts, service_factsthe operating system finished booting correctly
Is it healthy?an application probe, cluster membershipthe host is doing its job

They are strictly ordered, and each one failing means something different. Skipping to the third produces confusing failures: an application probe against a host that is still booting fails for reasons that have nothing to do with the application.

Readiness: systemctl is-system-running

The command reports one of eight states and exits zero for exactly one of them.

StateMeaningExit code
initializingearly bootup, before basic.targetnon-zero
startinglate bootup, job queue not yet idlenon-zero
runningfully operational0
degradedoperational, but one or more units failednon-zero
maintenancerescue or emergency target is activenon-zero
stoppingthe manager is shutting downnon-zero
offlinethe manager is not runningnon-zero
unknownstate could not be determinednon-zero

degraded is the state this whole lesson exists for. The system is operational. SSH works. Everything looks fine. One or more units failed to start — and on a host you just patched, the unit that failed is disproportionately likely to be the one whose package you just replaced.

The --wait option is important for post-reboot use: it blocks until the boot process completes rather than reporting initializing or starting. Without it, running the check too early returns a non-zero state that means “not finished yet”, which is indistinguishable in the exit code from “broken”.

Read-only / Safethe readiness gate
- name: Wait for systemd to finish booting and report its state
ansible.builtin.command:
  argv: [systemctl, is-system-running, '--wait']
register: system_state
changed_when: false
failed_when: false

- name: Refuse to continue with a system that is not fully running
ansible.builtin.assert:
  that: system_state.stdout | trim == 'running'
  fail_msg: >-
    {{ inventory_hostname }} came back in state
    '{{ system_state.stdout | trim }}' rather than 'running'.
    Investigate with: systemctl --failed
  success_msg: '{{ inventory_hostname }} reports running'

Asserting on the stdout string rather than the exit code is deliberate. Both give you pass or fail; only the string tells you which of the seven failure states you are in, and degraded and maintenance want very different responses.

Readiness: services, from facts rather than shell

ansible.builtin.service_facts populates ansible_facts.services. The structure was confirmed by running it under ansible-core 2.21.3: a dictionary keyed by service name, each value carrying name, source, state and — for systemd units — status.

Read-only / Safewhat service_facts actually returns
$ ansible -i localhost, -c local localhost -m service_facts
"ModemManager.service": {
"name": "ModemManager.service",
"source": "systemd",
"state": "running",
"status": "enabled"
}
"NetworkManager.service": {
"name": "NetworkManager.service",
"source": "systemd",
"state": "stopped",
"status": "not-found"
}
"apparmor": {
"name": "apparmor",
"source": "sysv",
"state": "running"
}

Two details in that output are worth carrying.

status: not-found with state: stopped. A unit name can appear in the dictionary without the unit existing — systemd knows the name because something references it. So a check written as “the key is present and state is not running” will flag units that were never installed. Check status too, or check for state == 'running' directly.

source: sysv entries have no status key. A filter that assumes status exists on every entry raises an undefined-attribute error on the first host with a SysV init script. Use | default('').

Read-only / Safeasserting the services this host owns are running
- name: Collect service state
ansible.builtin.service_facts:

- name: Assert every service this host owns is running
ansible.builtin.assert:
  that:
    - ansible_facts.services[item] is defined
    - ansible_facts.services[item].state | default('') == 'running'
  fail_msg: >-
    {{ inventory_hostname }}: {{ item }} is
    '{{ ansible_facts.services[item].state | default("absent") }}'
    after the reboot, not running.
loop: '{{ host_required_services }}'
loop_control:
  label: '{{ item }}'

host_required_services belongs in group_vars, next to the group that defines what the host is for. A validation step that hard-codes nginx is a validation step for one tier that gets copied and half-edited for the next.

Readiness: mounts

A host that boots without one of its filesystems is the classic silent post-reboot failure. The application starts, writes to what it thinks is the data directory, and is actually writing to the root filesystem underneath an empty mountpoint. Nothing errors. Disk usage on / climbs for a week.

Read-only / Safeasserting the filesystems came back
- name: Refresh facts after the reboot
ansible.builtin.setup:
  gather_subset:
    - '!all'
    - '!min'
    - hardware

- name: Assert every expected filesystem is mounted
ansible.builtin.assert:
  that: >-
    ansible_facts.mounts | selectattr('mount', 'equalto', item)
    | list | length == 1
  fail_msg: >-
    {{ inventory_hostname }}: {{ item }} is not mounted after the
    reboot. Anything writing there is writing to the parent filesystem.
loop: '{{ host_required_mounts }}'

Health: the application probe

Readiness says the operating system is fine. Health says the service is serving.

Read-only / Safea probe that asserts on content, not on 200
- name: Wait for the application to report itself healthy
ansible.builtin.uri:
  url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
  return_content: true
  status_code: [200]
  timeout: 10
register: health
retries: 30
delay: 5
until:
  - health.status | default(0) == 200
  - health.json.database | default('') == 'connected'
  - health.json.version | default('') == expected_version
changed_when: false

Every default() in those conditions is chosen so an absent field fails. health.json.database | default('') compared against 'connected' is false when the field is missing, which is the correct reading: a health endpoint that stopped reporting a field is not a health endpoint that is reporting good news.

The version check is the one people leave out and the one that catches the worst outcome. A host that rebooted into the old kernel, or whose package upgrade silently rolled back, will pass every other check in this lesson. Asserting that the running version is the version you deployed is the only check that catches it.

Health: cluster membership

For anything clustered — a database replica set, a Ceph OSD host, an etcd member, a message broker — “the process is running” is a particularly weak claim. A node can run, accept connections and be outside the cluster.

Read-only / Safeasking the cluster, not the node
- name: Ask the cluster whether this node has rejoined
ansible.builtin.uri:
  url: 'http://{{ cluster_api_host }}/v1/members'
  return_content: true
delegate_to: '{{ groups["cluster"] | difference([inventory_hostname]) | first }}'
register: members
retries: 20
delay: 15
until: >-
  members.json.members | default([])
  | selectattr('name', 'equalto', inventory_hostname)
  | selectattr('healthy', 'equalto', true)
  | list | length == 1
changed_when: false

The delegate_to is what makes this meaningful: the question is put to a node other than the one being validated. A node asked about itself will report itself present.

Placing validation so its failure matters

Everything above is worthless if a failure gets recorded and the run carries on.

Service impact possiblethe batch shape, with validation as the gate
- name: Patch and reboot with validation gating the next batch
hosts: '{{ patch_target }}'
become: true
serial: '{{ patch_serial }}'
max_fail_percentage: 0
tasks:
  - name: Apply updates
    ansible.builtin.include_tasks: patch.yml

  - name: Reboot if required
    ansible.builtin.reboot:
      reboot_timeout: 900
      post_reboot_delay: 30
    when: reboot_required | default(false)

  - name: Refresh facts, then validate
    ansible.builtin.include_tasks: validate.yml

Three properties of that shape are worth stating explicitly.

Validation is a task in the same play, not a separate playbook run afterwards. A separate run cannot stop a batch that has already finished.

It comes after the reboot in the task list, so the batch is not considered complete until it passes.

ignore_errors appears nowhere. A validation task with ignore_errors: true is a log line, not a gate, and the difference is invisible in the playbook until the night it matters.

Read-only / Safea validation failure stopping the run, executed on 2.21.3
$ ansible-playbook -i inv10.ini batchstop_canary.yml
TASK [Post-reboot validation gate (h01 fails it)] ******************************
fatal: [h01]: FAILED! => {
  "assertion": "inventory_hostname != 'h01'",
  "changed": false,
  "evaluated_to": false,
  "msg": "h01 came back but its service did not start"
}

NO MORE HOSTS LEFT *************************************************************

NO MORE HOSTS LEFT *************************************************************

PLAY RECAP *********************************************************************
h01                        : ok=1    changed=0    unreachable=0    failed=1

exit=2

One host in the recap, out of ten in the inventory. The other nine were never touched. That is what a working gate looks like: the evidence of its success is a recap that is shorter than the fleet.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A host returns from a patch reboot and systemctl is-system-running reports degraded. What does that mean?

  2. Q2. A patch play validates mounts and kernel version using ansible_facts immediately after the reboot task, without re-gathering. Why is this check useless?

  3. Q3. Which of these correctly describe what a --check run of a patch play does and does not verify? Select all that apply.

  4. Q4. Running the validation as a separate playbook after the patch playbook gives the same protection as putting it inside the patch play, as long as it runs immediately afterwards.

Passing score: 75%. Answers are checked in this browser.