Playbooks and Idempotency
Writing playbooks: plays and tasks, changed and ok, handlers, check and diff mode, conditionals, loops, and why a good playbook can run twice without harm.
Playbooks, plays and tasks
Ad-hoc commands (see
Ansible Basics) do
one thing once. To describe how a server should be configured — and
keep it that way — you write a
playbook: a YAML file
containing an ordered list of plays, which Ansible runs from top to
bottom with ansible-playbook. It lives in version
control next to the inventory, so every change to a server starts as
a reviewable change to a file. (Security teams use the word
"playbook" for an
incident response procedure; on these pages it always means the Ansible file.)
This is site.yml, the playbook Kestrel Energy uses for
the portal web servers and the database server. It is the real file
from the recordings below (kestrel_root points each
host at its own test folder; on real servers the paths would start
at /):
- name: Configure Kestrel web servers
hosts: web
gather_facts: false
tasks:
- name: Create directories
ansible.builtin.file:
path: "{{ kestrel_root }}/{{ item }}"
state: directory
mode: "0755"
loop:
- etc/nginx/conf.d
- var/www/portal
- name: Render portal upstream
ansible.builtin.template:
src: portal-upstream.conf.j2
dest: "{{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf"
mode: "0644"
notify: Reload nginx
- name: Set worker connections
ansible.builtin.lineinfile:
path: "{{ kestrel_root }}/etc/nginx/nginx.conf"
regexp: '^\s*worker_connections'
line: " worker_connections {{ nginx_worker_connections }};"
create: true
mode: "0644"
notify: Reload nginx
- name: Install maintenance page
ansible.builtin.copy:
content: "<h1>Kestrel Energy portal is under maintenance</h1>\n"
dest: "{{ kestrel_root }}/var/www/portal/maintenance.html"
mode: "0644"
handlers:
- name: Reload nginx
ansible.builtin.debug:
msg: "nginx on {{ inventory_hostname }} would be reloaded now"
- name: Configure the database server
hosts: db
gather_facts: false
tasks:
- name: Create config directory
ansible.builtin.file:
path: "{{ kestrel_root }}/etc/postgresql/16/main"
state: directory
mode: "0755"
- name: Allow the web servers in pg_hba.conf
ansible.builtin.blockinfile:
path: "{{ kestrel_root }}/etc/postgresql/16/main/pg_hba.conf"
create: true
mode: "0640"
block: |
{% for host in groups['web'] %}
host portal portal_app {{ hostvars[host].ansible_host }}/32 scram-sha-256
{% endfor %}
- name: Enable nightly billing export (production only)
ansible.builtin.copy:
content: "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
dest: "{{ kestrel_root }}/etc/cron.d/billing-export"
mode: "0644"
when: kestrel_env == "prod"
Plays
A play is one entry in the
playbook's top-level list: it names the hosts it targets
(hosts:, any
host pattern), the
settings for them (gather_facts, become,
vars) and the tasks and handlers to run there.
site.yml has two plays: one for the group
web, one for db. Plays run one after the
other; within a play, Ansible runs each task on all targeted hosts
in parallel before it moves to the next task.
Tasks
A task is one call to one
module, with a human-readable name and the module's
arguments, plus optional keywords that control it
(loop, when, notify,
register, become, tags).
Write module names in full (ansible.builtin.copy; see
Roles and Collections)
and give every task a name that says what state it ensures,
because that name is what appears in the output and in a reviewer's
head.
YAML details matter here: indentation decides what belongs to what,
and file modes must be quoted strings (mode: "0644"),
because an unquoted 644 is read as a decimal number and
produces very wrong permissions (see
Configuration Languages: HCL, YAML and JSON).
Running a playbook
Check the syntax first; it parses the YAML and the playbook structure without contacting any host:
$ cd /home/amara/kestrel-infra/ansible
$ ansible-playbook site.yml --syntax-check
playbook: site.yml
Then the first real run, on hosts that do not have any of this configuration yet:
$ ansible-playbook site.yml
PLAY [Configure Kestrel web servers] *******************************************
TASK [Create directories] ******************************************************
changed: [web02] => (item=etc/nginx/conf.d)
changed: [web01] => (item=etc/nginx/conf.d)
changed: [web02] => (item=var/www/portal)
changed: [web01] => (item=var/www/portal)
TASK [Render portal upstream] **************************************************
changed: [web02]
changed: [web01]
TASK [Set worker connections] **************************************************
changed: [web01]
changed: [web02]
TASK [Install maintenance page] ************************************************
changed: [web01]
changed: [web02]
RUNNING HANDLER [Reload nginx] *************************************************
ok: [web01] => {
"msg": "nginx on web01 would be reloaded now"
}
ok: [web02] => {
"msg": "nginx on web02 would be reloaded now"
}
PLAY [Configure the database server] *******************************************
TASK [Create config directory] *************************************************
changed: [db01]
TASK [Allow the web servers in pg_hba.conf] ************************************
changed: [db01]
TASK [Enable nightly billing export (production only)] *************************
skipping: [db01]
PLAY RECAP *********************************************************************
db01 : ok=2 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web01 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Each task prints one line per host (per loop item for a loop):
changed when the module had to act, ok
when the host was already in the desired state,
skipping when a condition was false, and
fatal: … FAILED! or UNREACHABLE! when
something went wrong. The order of web01 and web02 lines varies from
run to run, because the hosts are handled in parallel.
The handler is a debug message in this test setup; on
the real servers it would reload nginx. The billing export was
skipped because these hosts are the test environment (kestrel_env: test). The files the play wrote, on web02 and db01:
# Ansible managed
upstream kestrel_portal {
server 192.0.2.11:8080;
server 192.0.2.12:8080;
}
# BEGIN ANSIBLE MANAGED BLOCK
host portal portal_app 192.0.2.11/32 scram-sha-256
host portal portal_app 192.0.2.12/32 scram-sha-256
# END ANSIBLE MANAGED BLOCK
The upstream file comes from a Jinja2 template and the
pg_hba.conf lines from a loop over the inventory group
web: add a web03 to the inventory and both files gain a
line on the next run. How that works is the subject of
Variables, Facts and Templates.
Reading the play recap
The PLAY RECAP is the summary Ansible prints at the end of every run: one line per host with seven counters. Read it before anything else, the way you read the summary line of a Terraform plan.
| Counter | Counts the tasks that… |
|---|---|
ok |
ran successfully, whether or not they changed anything |
changed |
changed something on the host |
unreachable |
could not connect (the host is dropped from the run) |
failed |
failed (the host is dropped from the rest of the play) |
skipped |
did not run because a condition was false |
rescued |
failed inside a block whose rescue section then
ran
|
ignored |
failed, but had ignore_errors: true |
For web01, ok=5 is the four tasks plus the handler, and
changed=4 the four tasks that changed something (a loop
counts as one task). ansible-playbook exits with
exit status
0 when no host failed, 2 when a task failed on some host, and 4 when
hosts were unreachable, which is what a pipeline acts on.
Idempotency: the second run
Run the same playbook again, without changing anything:
$ ansible-playbook site.yml
PLAY [Configure Kestrel web servers] ******************************************* TASK [Create directories] ****************************************************** ok: [web01] => (item=etc/nginx/conf.d) ok: [web02] => (item=etc/nginx/conf.d) ok: [web01] => (item=var/www/portal) ok: [web02] => (item=var/www/portal) TASK [Render portal upstream] ************************************************** ok: [web01] ok: [web02] TASK [Set worker connections] ************************************************** ok: [web02] ok: [web01] TASK [Install maintenance page] ************************************************ ok: [web02] ok: [web01] PLAY [Configure the database server] ******************************************* TASK [Create config directory] ************************************************* ok: [db01] TASK [Allow the web servers in pg_hba.conf] ************************************ ok: [db01] TASK [Enable nightly billing export (production only)] ************************* skipping: [db01] PLAY RECAP ********************************************************************* db01 : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0 web01 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 web02 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Every task is ok, changed=0 on every host,
and the handler did not run because nothing notified it. This is
idempotence: running the playbook once or ten times leaves the hosts in the
same state. It is what makes it safe to run the playbook after every
merge, on a schedule, or "just to be sure" during an incident — and
it turns changed into information: on a host that
should already be correct, a change means someone or something
altered it (see diff mode below).
The idempotency comes from the modules. file,
template, lineinfile,
blockinfile and copy all compare the
desired state with the actual state and act only on the difference.
The difference with a shell habit is easy to model. Here an admin's
hand-tuned line is "fixed" twice by each method:
def append_line(lines, line):
"""Like `echo line >> nginx.conf`: always adds the line."""
return lines + [line], True
def ensure_line(lines, prefix, line):
"""Like ansible.builtin.lineinfile with a regexp: fix the matching line, or add it."""
for number, current in enumerate(lines):
if current.strip().startswith(prefix):
if current == line:
return lines, False # already right: report ok
return lines[:number] + [line] + lines[number + 1:], True
return lines + [line], True
hand_tuned = [" worker_connections 4096; # tuned by hand"]
wanted = " worker_connections 1024;"
methods = {
"echo >>": lambda lines: append_line(lines, wanted),
"lineinfile": lambda lines: ensure_line(lines, "worker_connections", wanted),
}
for name, method in methods.items():
nginx_conf = hand_tuned
for run in (1, 2):
nginx_conf, changed = method(nginx_conf)
print(f"{name:10} run {run}: changed={changed}, {len(nginx_conf)} line(s)")
print(" " + " | ".join(line.strip() for line in nginx_conf))
echo >> run 1: changed=True, 2 line(s)
echo >> run 2: changed=True, 3 line(s)
worker_connections 4096; # tuned by hand | worker_connections 1024; | worker_connections 1024;
lineinfile run 1: changed=True, 1 line(s)
lineinfile run 2: changed=False, 1 line(s)
worker_connections 1024;
Appending grows the file on every run and leaves the conflicting old line in place; the state-based version converges on one correct line and then reports "nothing to do".
Making commands idempotent
command and shell cannot compare states:
they run, and report changed. Three tools tell them
what "already done" or "changed" means:
- name: Initialize the billing export schema (once)
ansible.builtin.command:
cmd: /opt/kestrel/bin/export-billing --init-schema
creates: /var/lib/kestrel/billing/.schema-initialized # skip if this file exists
- name: Check the PostgreSQL client version
ansible.builtin.command: psql --version
register: psql_version
changed_when: false # reading never changes anything
- name: Reload the portal configuration
ansible.builtin.command: /opt/kestrel/bin/portalctl reload
register: reload
changed_when: "'reloaded' in reload.stdout" # changed only if it says so
-
creates:(and its oppositeremoves:) skips the command when a file exists (or is absent). -
changed_when is a task keyword
with a condition that decides whether the task reports
changed, overriding what the module said:falsefor read-only commands, or an expression on the registered result. -
failed_whendoes the same for failure, for commands whose exit status does not mean what Ansible assumes.
Handlers
A handler is a task that
runs only when another task notifies it and that task reports
changed. Handlers are listed under
handlers:, are notified by name with
notify:, and run after the play's tasks (strictly:
after each of pre_tasks, roles/tasks
and post_tasks), once per host however many tasks
notified them. The classic use is "restart or reload the service
when its configuration changed": in the first run above, two tasks
notified Reload nginx and it ran once per web server;
in the second run nothing changed, so nginx would not have been
touched.
Two details catch people out:
- Handlers wait until the end of the play. If a later task needs the reload to have happened (a smoke test against the new configuration), run the pending handlers at that point:
- name: Apply pending handlers before the smoke test
ansible.builtin.meta: flush_handlers
- name: Check that the portal answers
ansible.builtin.uri:
url: "http://{{ ansible_host }}:8080/health"
status_code: 200
-
If a task fails on a host after a handler was notified, the host
leaves the play and the handler does not run there — the
new configuration is on disk, but nginx still runs the old one.
The next run will not notify it again, because the file is already
correct.
--force-handlers(orforce_handlers: trueon the play) runs notified handlers even after a failure.
Check mode and diff mode
Check mode
(--check) runs a playbook without changing anything:
each module that supports it reports what it would do.
Diff mode (--diff)
makes modules that support it show the change itself, as a
before/after diff of a file. Together they are Ansible's
dry run. Here Ravi Kumar has changed the portal port to 8081 in a variable
file and checks the effect on one host first:
$ ansible-playbook site.yml --check --diff --limit web01
PLAY [Configure Kestrel web servers] *******************************************
TASK [Create directories] ******************************************************
ok: [web01] => (item=etc/nginx/conf.d)
ok: [web01] => (item=var/www/portal)
TASK [Render portal upstream] **************************************************
--- before: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/conf.d/portal-upstream.conf
+++ after: /home/amara/.ansible/tmp/ansible-local-24822247e1m8vun/tmpwvgc202d/portal-upstream.conf.j2
@@ -1,5 +1,5 @@
# Ansible managed
upstream kestrel_portal {
- server 192.0.2.11:8080;
- server 192.0.2.12:8080;
+ server 192.0.2.11:8081;
+ server 192.0.2.12:8081;
}
changed: [web01]
TASK [Set worker connections] **************************************************
ok: [web01]
TASK [Install maintenance page] ************************************************
ok: [web01]
RUNNING HANDLER [Reload nginx] *************************************************
ok: [web01] => {
"msg": "nginx on web01 would be reloaded now"
}
PLAY [Configure the database server] *******************************************
skipping: no hosts matched
PLAY RECAP *********************************************************************
web01 : ok=5 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The diff shows exactly which lines would change, and the recap says
changed=1 — but the file on web01 still contains port
8080 afterward; nothing was written. (The handler "ran" because a
debug task changes nothing either way; a real
service-reload handler is also simulated in check mode.) A check
run's changed count is a prediction, not a result.
Diff mode is just as useful on a real run, where it documents what
was changed. Someone had edited nginx.conf on web01 by
hand during a busy evening (worker_connections 4096);
the next normal run with --diff shows the drift and
puts the managed value back:
$ ansible-playbook site.yml --diff --limit web01
… TASK [Set worker connections] ************************************************** --- before: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/nginx.conf (content) +++ after: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/nginx.conf (content) @@ -1 +1 @@ - worker_connections 4096; # tuned by hand + worker_connections 1024; changed: [web01] … PLAY RECAP ********************************************************************* web01 : ok=5 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
That is configuration management repairing configuration drift. Whether repairing it was right is a separate question: if the hand-tuning fixed a real problem, the fix belongs in the variable files, through a reviewed change, before the next run removes it.
Conditionals with when
when: runs a task only on hosts where an expression is
true; elsewhere the task is skipping, as the billing
export task was. The value is a bare Jinja2 expression — no
{{ }} around it — and a list of conditions means "all
of them":
- name: Enable the nightly billing export
ansible.builtin.copy:
content: "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
dest: /etc/cron.d/billing-export
mode: "0644"
when:
- kestrel_env == "prod"
- inventory_hostname in groups['db']
- name: Show the maintenance page
ansible.builtin.file:
src: /var/www/portal/maintenance.html
dest: /var/www/portal/index.override.html
state: link
when: portal_maintenance | bool # "true", "yes" and true all become true
Since ansible-core 2.19, a condition must produce a real boolean.
Older versions accepted anything "truthy", which hid real mistakes;
now a non-boolean result is an error, for example "Conditional
result was 'localhost' of type 'str', which evaluates to True.
Conditionals must have a boolean result." A template inside an
expression (when: count + {{ extra }} > 2) is a
syntax error ("Template delimiters are not supported in
expressions"), and a condition wrapped entirely in
{{ }} produces a deprecation warning. Write
when: portal_maintenance | bool, not
when: "{{ portal_maintenance }}".
Loops
loop: runs a task once per item in a list, with the
current item available as item; the output shows one
line per item (changed: [web01] => (item=var/www/portal)). A list of dictionaries handles several values per item, and
loop_control: label keeps the output readable:
- name: Create the Kestrel service accounts
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
shell: /usr/sbin/nologin
system: true
loop:
- { name: portal, groups: www-data }
- { name: billing, groups: postgres }
loop_control:
label: "{{ item.name }}"
become: true
Older playbooks use with_items:,
with_dict: and similar; they still work, but
loop: with filters is the current style. Prefer a
module that takes a list itself where one exists: the
apt module installs a whole list of packages in one
transaction, which is faster than looping over it.
Failures, ignore_errors and blocks
When a task fails on a host, Ansible prints the error, removes that
host from the rest of the play, and carries on with the others. This
pre-deployment check reads the upstream file, counts the backends
and requires at least three (there are two, so it fails, but
ignore_errors lets the play continue); then a task
reads a file that does not exist, on web01 only:
- name: Pre-deployment checks for the portal
hosts: web
gather_facts: false
tasks:
- name: Read the upstream file
ansible.builtin.command: cat {{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf
register: upstream
changed_when: false
- name: Show what was registered
ansible.builtin.debug:
var: upstream.stdout_lines
- name: Count backend servers
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has {{ upstream.stdout_lines | select('search', 'server ') | list | length }} backends"
- name: Fail when fewer than three backends are configured
ansible.builtin.command: cat {{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf
register: check
changed_when: false
failed_when: check.stdout_lines | select('search', 'server ') | list | length < 3
ignore_errors: true
- name: Read a file that does not exist
ansible.builtin.command: cat {{ kestrel_root }}/etc/kestrel/feature-flags.yml
changed_when: false
when: inventory_hostname == "web01"
- name: This task never runs on web01
ansible.builtin.debug:
msg: "Still going on {{ inventory_hostname }}"
$ ansible-playbook checks.yml
…
TASK [Fail when fewer than three backends are configured] **********************
[ERROR]: Task failed: Action failed.
Origin: /home/amara/kestrel-infra/ansible/checks.yml:19:7
17 msg: "{{ inventory_hostname }} has {{ upstream.stdout_lines | select('search', 'server ') | list | length ...
18
19 - name: Fail when fewer than three backends are configured
^ column 7
fatal: [web01]: FAILED! => {"changed": false, "cmd": ["cat", "/home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/conf.d/portal-upstream.conf"], "delta": "0:00:00.003789", "end": "2026-10-06 18:05:17.114041", "failed_when_result": true, "msg": "", "rc": 0, "start": "2026-10-06 18:05:17.110252", "stderr": "", "stderr_lines": [], "stdout": "# Ansible managed\nupstream kestrel_portal {\n server 192.0.2.11:8080;\n server 192.0.2.12:8080;\n}", "stdout_lines": ["# Ansible managed", "upstream kestrel_portal {", " server 192.0.2.11:8080;", " server 192.0.2.12:8080;", "}"]}
...ignoring
…
TASK [Read a file that does not exist] *****************************************
skipping: [web02]
[ERROR]: Task failed: Module failed: non-zero return code
Origin: /home/amara/kestrel-infra/ansible/checks.yml:26:7
24 ignore_errors: true
25
26 - name: Read a file that does not exist
^ column 7
fatal: [web01]: FAILED! => {"changed": false, "cmd": ["cat", "/home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml"], "delta": "0:00:00.002898", "end": "2026-10-06 18:05:17.458042", "msg": "non-zero return code", "rc": 1, "start": "2026-10-06 18:05:17.455144", "stderr": "cat: /home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml: No such file or directory", "stderr_lines": ["cat: /home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml: No such file or directory"], "stdout": "", "stdout_lines": []}
TASK [This task never runs on web01] *******************************************
ok: [web02] => {
"msg": "Still going on web02"
}
PLAY RECAP *********************************************************************
web01 : ok=4 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=1
web02 : ok=5 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=1
Since ansible-core 2.19 a failure starts with an
[ERROR]: line and an Origin: line that
points to the file, line and column of the task, followed by the
classic fatal: [host]: FAILED! => {…} with the
module's full result. Read msg, rc and
stderr first: here rc 1 and "No such file
or directory". The ignored failure is marked
...ignoring and counted as ignored; the
real one removed web01 from the play, so the last task ran on web02
only, and the command exited with status 2.
block, rescue and always
A block groups tasks; rescue runs only if
a task in the block failed (like except in Python), and
always runs either way (like finally).
Kestrel uses it to roll back a release marker when the migration
check fails:
- name: Deploy meter-api release
hosts: web
gather_facts: false
vars:
release: "2026.10.2"
tasks:
- name: Deploy with automatic rollback
tags: [deploy]
block:
- name: Write release marker
ansible.builtin.copy:
content: "{{ release }}\n"
dest: "{{ kestrel_root }}/var/www/portal/RELEASE"
mode: "0644"
- name: Run database migration check
ansible.builtin.command: cat {{ kestrel_root }}/var/www/portal/migrations-ok
changed_when: false
rescue:
- name: Roll back release marker
ansible.builtin.copy:
content: "2026.10.1\n"
dest: "{{ kestrel_root }}/var/www/portal/RELEASE"
mode: "0644"
- name: Report the failure
ansible.builtin.debug:
msg: "Rolled back {{ inventory_hostname }}: {{ ansible_failed_result.stderr }}"
always:
- name: Log the attempt
ansible.builtin.lineinfile:
path: "{{ kestrel_root }}/var/www/portal/deploy.log"
line: "attempted {{ release }}"
create: true
mode: "0644"
- name: Show final release
tags: [report]
ansible.builtin.command: cat {{ kestrel_root }}/var/www/portal/RELEASE
register: final
changed_when: false
- name: Print final release
tags: [report]
ansible.builtin.debug:
msg: "{{ inventory_hostname }} runs {{ final.stdout }}"
Only web02 had the migrations-ok file:
$ ansible-playbook deploy.yml
…
TASK [Run database migration check] ********************************************
[ERROR]: Task failed: Module failed: non-zero return code
…
ok: [web02]
TASK [Roll back release marker] ************************************************
changed: [web01]
TASK [Report the failure] ******************************************************
ok: [web01] => {
"msg": "Rolled back web01: cat: /home/amara/kestrel-infra/ansible/hosts/web01/var/www/portal/migrations-ok: No such file or directory"
}
TASK [Log the attempt] *********************************************************
changed: [web02]
changed: [web01]
…
TASK [Print final release] *****************************************************
ok: [web01] => {
"msg": "web01 runs 2026.10.1"
}
ok: [web02] => {
"msg": "web02 runs 2026.10.2"
}
PLAY RECAP *********************************************************************
web01 : ok=6 changed=3 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0
web02 : ok=5 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web01 failed inside the block, ran the rescue and continued:
rescued=1, failed=0, exit status 0. That
is right when the rescue really restores a safe state; when it only
reports, re-raise the failure (for example with a final
ansible.builtin.fail task in rescue) so
the run does not look successful.
Running part of a playbook
A few options limit what a run touches — the same idea as a small blast radius:
-
--limit web01(-l) narrows the hosts to a subset of what the plays target: change one server, check it, then the rest. -
--tags reportruns only tasks with that tag;--skip-tagsdoes the opposite;--list-tagsand--list-tasksshow what exists without running anything. -
--start-at-task "…"skips everything before the named task, and--stepasks before each task. -
-vadds each task's full result to the output;-vvvalso shows connections and commands (see what happens when a module runs).
$ ansible-playbook deploy.yml --list-tasks
$ ansible-playbook deploy.yml --start-at-task "Print final release" --limit web02
playbook: deploy.yml
play #1 (web): Deploy meter-api release TAGS: []
tasks:
Write release marker TAGS: [deploy]
Run database migration check TAGS: [deploy]
Show final release TAGS: [report]
Print final release TAGS: [report]
PLAY [Deploy meter-api release] ************************************************
TASK [Print final release] *****************************************************
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'final' is undefined
…
fatal: [web02]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'final' is undefined"}
PLAY RECAP *********************************************************************
web02 : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
A real trap: starting at "Print final release" skipped the task that
registers final, so the variable does not exist.
Started one task earlier (--start-at-task "Show final release") the same run succeeds. Partial runs are for recovery and testing;
the normal run is the whole playbook, because only the whole
playbook is guaranteed to be consistent.