An Ansible playbook is an ordered orchestration of tasks. State-aware modules can record what a machine should have and repair missing pieces after a partial deploy. Convergence comes from the selected modules and arguments, not an engine-wide guarantee that every task safely repeats. A playbook is an executable contract between the inventory, the modules, and the operator who reviews the change.
Say the end, not each step
An imperative shell script says “run these commands in this order.” A state-oriented task says “this package is installed, this file has these bytes, and this service is enabled and running.” A module compares the current host with that target before it acts. Matching tasks report ok; only a mismatch reports changed. The declaration can therefore be applied to a fresh host, a partly configured host, or one edited by hand.
The target is not vague intent. Module arguments are the contract: state: present, an exact template, an owner and mode, or a service’s enabled/running pair. Keep shared values in variables and roles, but make the final value visible to a reviewer. A hidden default or a command that mutates state outside the module makes the contract harder to reason about.
Modules make reruns converge
Most built-in modules are designed to be idempotent. ansible.builtin.copy compares content and metadata before replacing a file. ansible.builtin.package asks the package manager for the requested state. ansible.builtin.service with state: started changes running state only when necessary; state: restarted requests a restart on each run. A second invocation should be a quiet confirmation, not another restart, duplicate line, or appended record.
Some actions are not naturally idempotent. A raw command that creates a user or calls an API repeats its side effect unless guarded. Prefer a purpose-built module; otherwise use when with a registered read result, or precise creates/removes conditions. changed_when controls reporting and handler notification, not execution. Setting it to false cannot prevent a repeated mutation.
Inventory is the map
The playbook describes outcomes, while inventory selects hosts and variables select context. ansible_user selects the login identity; an SSH agent or Vault can supply authentication material without committing plaintext secrets. Group variables let one declaration cover a fleet while preserving host-specific values such as a port or region. Run with an explicit inventory in CI so “localhost” does not accidentally mean the runner itself.
Check mode (--check) asks modules to predict changes without applying them, and --diff shows content differences for modules that support it. Prediction is not proof: unsupported modules may skip work, and task-level check_mode: false deliberately executes even with --check. Review those overrides; secrets should not be printed in a diff. Treat a check-mode report as review material, then run normal mode with the same inventory and keep the result with the deployment record.
When it breaks
A playbook can drift when a task shells out, a module has incomplete check-mode support, or an unpinned package resolves a different version tomorrow. A host failure can leave a fleet partly changed; serial bounds batches but provides no rollback. Define batch gates, handler failure behavior, and recovery explicitly. A task depending on a fact gathered earlier must state that dependency instead of relying on incidental order.
Scope confusion is another failure: an inventory containing localhost, with a local connection targets the runner, while all selects every matching inventory host. Syntax checking validates structure, not whether the selected hosts match the operator’s intent. Review inventory and --list-hosts; inspect effective variables, including command-line overrides. Protect secrets with no_log and diff: false. Database migrations and cloud APIs still need their own transaction and retry semantics.
Check it quickly
Save the YAML as site.yml and Python as verify_ansible.py in one directory, then run these commands. The local connection modifies only a temporary file.
python3 -m venv .venv-ansible
.venv-ansible/bin/python -m pip install ansible-core==2.21.4
.venv-ansible/bin/python verify_ansible.py
- name: Converge a disposable local file
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Declare the banner content and mode
ansible.builtin.copy:
dest: "{{ fixture }}"
content: "managed by ansible\n"
mode: "0644"
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
playbook = Path(__file__).with_name("site.yml").resolve()
ansible = Path(sys.executable).parent / "ansible-playbook"
print(subprocess.check_output([str(ansible), "--version"], text=True).splitlines()[0])
with tempfile.TemporaryDirectory(prefix="ansible-state-") as directory:
work = Path(directory)
banner = work / "banner"
config = work / "ansible.cfg"
config.write_text("[defaults]\nstdout_callback=default\n", encoding="utf-8")
env = dict(os.environ, ANSIBLE_CONFIG=str(config), ANSIBLE_NOCOLOR="1",
ANSIBLE_LOCAL_TEMP=str(work / "local-tmp"))
variables = {"fixture": str(banner), "ansible_python_interpreter": sys.executable,
"ansible_remote_tmp": str(work / "module-tmp")}
command = [str(ansible), "-i", "localhost,", str(playbook),
"--extra-vars", json.dumps(variables)]
def run(*options):
result = subprocess.run(command + list(options), cwd=work, env=env,
check=True, text=True, capture_output=True, timeout=60)
print(result.stdout)
return result.stdout
def changed(expected, *options):
output = run(*options)
recap = re.search(r"localhost\s+:\s+ok=(\d+)\s+changed=(\d+)\s+"
r"unreachable=(\d+)\s+failed=(\d+)", output)
assert recap and tuple(map(int, recap.groups())) == (1, expected, 0, 0)
return output
run("--syntax-check")
assert "managed by ansible" in changed(1, "--check", "--diff")
assert not banner.exists(), "check mode created the absent file"
changed(1)
assert banner.read_bytes() == b"managed by ansible\n"
assert stat.S_IMODE(banner.stat().st_mode) == 0o644
changed(0)
banner.write_bytes(b"manual drift\n")
before = (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
diff = changed(1, "--check", "--diff")
assert "manual drift" in diff and "managed by ansible" in diff
assert before == (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
changed(1)
assert banner.read_bytes() == b"managed by ansible\n"
changed(0)
print("PASS: syntax, absent-file preview, convergence, drift preview, and repair")
The following describes the results the script is intended to check with Ansible Core 2.21.4: syntax checking succeeds. Check mode reports changed=1 without creating the absent file; normal execution creates it and a rerun reports changed=0. After manual drift, the preview shows both versions without changing bytes, mode, or modification time. Normal execution repairs the file and converges again. These assertions are used to check this copy task; other modules and upgrades need their own checks.
The retained 2026-10-15 review record confirms only that the version probe and syntax check passed. The exact verifier then failed during its first absent-file --check --diff invocation because of the local IPC restriction: Local RPC server did not start, exit status 250, before the first task ran. That run did not confirm the expected preview, creation, convergence, drift-preview or repair results. Earlier author observations remain historical; this retained attempt did not reproduce them.
Further reading
Sources
- Ansible playbooks introduction, playbook structure, tasks, and desired state, checked 2026-09-09.
- Ansible check mode and diff mode, prediction and change previews, checked 2026-09-09.
- Ansible error handling, changed reporting and handler notification, checked 2026-09-10.
- Ansible service module, started versus restarted semantics, checked 2026-10-15.