AnsibleVII · Ad-Hoc ExecutionAd-hoc execution
Promoting an ad-hoc command to a playbook
What you'll learn
- Convert a sequence of ad-hoc commands into an equivalent, reviewable play
- Replace command and shell invocations with purpose-built modules during the conversion
- Add the guardrails a one-liner cannot carry: assertions, handlers, validation and backups
- State precisely what the conversion gained, rather than asserting that playbooks are better
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
“Write it as a playbook instead” is easy advice to give and vague advice to follow. This lesson does the conversion properly, on one realistic task, and then accounts for what changed.
The important claim is not that the playbook is longer. It is that four specific capabilities appear that the one-liners could not express, and one of them — accurate change reporting — is not a matter of style at all.
The task
Point the web tier at the correct NTP servers. Someone did this last quarter with three ad-hoc commands.
$ ansible web -i inventory.ini -b -m ansible.builtin.shell -a "apt-get install -y chrony"$ ansible web -i inventory.ini -b -m ansible.builtin.copy -a "src=./chrony.conf dest=/etc/chrony/chrony.conf"$ ansible web -i inventory.ini -b -m ansible.builtin.shell -a "systemctl restart chronyd"It worked. Time synchronised, nobody complained. Now count the problems:
shell: apt-get install -yis Debian-only and reportschangedon every run, including the run where the package was already installed.copypushes the same file to every host, so any per-host difference has to be handled by hand or not at all.- Nothing validates the config before it lands. A malformed file is discovered by step 3, after it is already on 40 machines.
- No backup. The previous config is gone.
- Step 3 restarts everywhere, unconditionally, including on hosts where step 2 changed nothing — and including on hosts where step 2 failed.
- Rerunning the sequence reports three changes on every host, every time, so “did anything change?” is unanswerable.
The conversion
Two of the three modules change during the promotion, which is typical.
shell was doing work a purpose-built module does better; copy was
standing in for a template.
# ntp.yml
- name: Ensure chrony is installed, configured and running
hosts: web
become: true
vars:
chrony_servers:
- 192.0.2.10
- 192.0.2.11
tasks:
- name: Refuse to run against a host that is not a web server
ansible.builtin.assert:
that:
- "'web' in group_names"
fail_msg: "This play only applies to the web group."
- name: Install chrony
ansible.builtin.package:
name: chrony
state: present
- name: Write the chrony configuration
ansible.builtin.template:
src: templates/chrony.conf.j2
dest: /etc/chrony/chrony.conf
owner: root
group: root
mode: '0644'
backup: true
validate: '/usr/sbin/chronyd -f %s -Q'
notify: Restart chrony
- name: Ensure chrony is enabled and running
ansible.builtin.service:
name: chronyd
state: started
enabled: true
handlers:
- name: Restart chrony
ansible.builtin.service:
name: chronyd
state: restarted
With the template beside it:
# templates/chrony.conf.j2
# Managed by Ansible. Local edits will be overwritten.
{% for server in chrony_servers %}
server {{ server }} iburst
{% endfor %}
driftfile /var/lib/chrony/chrony.drift
makestep 1.0 3
rtcsync
Before running anything, the play can be checked in three read-only ways:
$ ansible-playbook -i inventory.ini --syntax-check ntp.ymlplaybook: ntp.yml$ ansible-playbook -i inventory.ini --list-hosts ntp.ymlplaybook: ntp.yml
play #1 (web): Ensure chrony is installed, configured and running TAGS: []
pattern: ['web']
hosts (3):
web01.example.com
web02.example.com
web03.example.com$ ansible-playbook -i inventory.ini --check --diff ntp.ymlWhat the conversion bought, item by item
Not “playbooks are better”. Five specific things, each traceable to a line in the YAML.
1. Accurate change reporting
shell: apt-get install -y chrony reports changed every time, because
shell cannot know whether the package was already there.
ansible.builtin.package with state: present reports changed: true
on the run that installs it and changed: false on every run after. That
is not cosmetic. It is the difference between a fleet report you can read
and a wall of noise, and it is what makes the next item possible at all.
2. A handler that fires only where something changed
The notify: Restart chrony on the template task means the restart
happens only on hosts whose config actually changed. On a rerun where
nothing differs, zero services are restarted.
Compare the original: step 3 restarted chronyd on all 40 hosts every
time, including the 39 where nothing had changed, and including hosts
where step 2 had failed and left the old config in place. The one-liner
form cannot express the dependency, because there is nothing to hang it
on — the second command has no access to the first command’s result.
3. Validation before the file lands
validate: '/usr/sbin/chronyd -f %s -Q' runs chronyd against the
rendered file in a temporary location and refuses to install it if the
check fails. The %s is replaced with the temporary path.
A malformed config now fails on the first host, before it is written, and the play stops. In the one-liner version the malformed file reached every host and was discovered by the restart.
4. A backup, so the change is reversible
backup: true writes a timestamped copy of the previous file beside it
before overwriting. This is the answer to the question the previous
lesson said no ad-hoc mechanism can answer: what was there before?
It costs one line and it is the difference between a change you can undo on the host and one you can only undo from memory.
5. A precondition that fails closed
The assert refuses to proceed on any host that is not in the web
group. If someone runs this play with --limit db01.example.com, or
edits hosts: in a hurry, the play fails on that host instead of
configuring it.
This is the guardrail with no ad-hoc equivalent whatsoever. A one-liner has nowhere to put a precondition; the pattern is the precondition, and it is checked by nobody.
What a rerun looks like
This is the property that makes the play maintainable, and the one you should verify on your own conversions.
$ ansible-playbook -i inventory.ini ntp.ymlPLAY RECAP
web01.example.com : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03.example.com : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Illustrative output
$ ansible-playbook -i inventory.ini ntp.ymlPLAY RECAP
web01.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web03.example.com : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Illustrative output
changed=0 on the second run is the signal. It means the play describes
a state rather than performing an action, which is what makes it safe to
run on a schedule, safe to rerun after a partial failure, and useful as a
drift detector.
ok=4 rather than ok=5 on the second run is not a mistake: the handler
did not run, so there is one fewer task result.
Knowledge check
Knowledge check · 4 questions
Q1. The three ad-hoc commands restarted chronyd on all 40 hosts every time. What in the playbook version changes that, and what does it depend on?
Q2. Which of these could not be expressed by the original three ad-hoc commands at all? Select all that apply.
Q3. Wrapping the original shell commands in a playbook, unchanged, still leaves you without accurate change reporting, check-mode support, and a meaningful handler trigger.
Q4. On the second consecutive run the recap shows ok=4 changed=0 where the first showed ok=5 changed=3. What does the drop from 5 to 4 indicate?
Passing score: 75%. Answers are checked in this browser.