Skip to main content
RunBook Academy

AnsibleVII · Ad-Hoc ExecutionAd-hoc execution

Promoting an ad-hoc command to a playbook

Intermediate⏱ ~20 minansibleansible-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

Not yet marked complete on this device.

“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.

Configuration changethe original, step 1
$ ansible web -i inventory.ini -b -m ansible.builtin.shell -a "apt-get install -y chrony"
Configuration changethe original, step 2
$ ansible web -i inventory.ini -b -m ansible.builtin.copy -a "src=./chrony.conf dest=/etc/chrony/chrony.conf"
Service impact possiblethe original, step 3
$ ansible web -i inventory.ini -b -m ansible.builtin.shell -a "systemctl restart chronyd"

It worked. Time synchronised, nobody complained. Now count the problems:

  1. shell: apt-get install -y is Debian-only and reports changed on every run, including the run where the package was already installed.
  2. copy pushes the same file to every host, so any per-host difference has to be handled by hand or not at all.
  3. Nothing validates the config before it lands. A malformed file is discovered by step 3, after it is already on 40 machines.
  4. No backup. The previous config is gone.
  5. Step 3 restarts everywhere, unconditionally, including on hosts where step 2 changed nothing — and including on hosts where step 2 failed.
  6. 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:

Read-only / Safedoes it parse
$ ansible-playbook -i inventory.ini --syntax-check ntp.yml
playbook: ntp.yml
Read-only / Safewhat would it do, and to whom
$ ansible-playbook -i inventory.ini --list-hosts ntp.yml
playbook: 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
Read-only / Safepredict the changes
$ ansible-playbook -i inventory.ini --check --diff ntp.yml

What 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.

Configuration changefirst run
$ ansible-playbook -i inventory.ini ntp.yml
PLAY 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=0

Illustrative output

Configuration changesecond run, immediately after
$ ansible-playbook -i inventory.ini ntp.yml
PLAY 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=0

Illustrative 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

  1. 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?

  2. Q2. Which of these could not be expressed by the original three ad-hoc commands at all? Select all that apply.

  3. 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.

  4. 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.