Red Hat Certified Engineer (RHCE) #5: Playbook Basics — task, handler, idempotency
In #4 ad-hoc commands we ran a single module on the fly to change host state. This time we move on to the playbook, which bundles those tasks into a file and composes them declaratively. The deliverable graded on the RHCE hands-on exam is, in the end, a playbook. Once you’ve written one, it has to reproduce the same result any number of times, and the property that guarantees that reproducibility is exactly idempotency. In this post we’ll work through the structure of a playbook, writing tasks, handlers and notify, and verifying idempotency — all from a hands-on exam point of view.
What a playbook is #
A playbook is a YAML file that defines multiple tasks in order. Where an ad-hoc command is a one-off tool that runs one module once, a playbook is a form that preserves the same work in a file so you can run it repeatedly and keep it under version control. The proctor runs the playbook you wrote on the exam machine and checks the result, so nearly every RHCE hands-on answer is submitted as a playbook.
A playbook is made up of one or more plays, and each play defines which tasks to apply to which hosts. Inside a play go the target (hosts), privilege escalation (become), and the list of work to actually perform (tasks).
The YAML structure of a playbook #
Let’s look at the simplest possible playbook.
---
- name: Basic web server configuration
hosts: webservers
become: true
tasks:
- name: Install the httpd package
ansible.builtin.dnf:
name: httpd
state: present
- name: Start httpd and enable it at boot
ansible.builtin.service:
name: httpd
state: started
enabled: trueBreaking this file’s structure down:
- The
---at the top of the file marks the start of a YAML document. - The top level is a list of plays. The example above has only one play, so there’s a single item starting with
-. - Each play has a target
hosts, a privilege-escalation flagbecome, and a work listtasks. - Each item under
tasksis a single task, and each task calls one module.
The play–task relationship #
A play is a bundle of work applied to a set of hosts. You can put several plays in a single playbook, and each play can target a different host group.
---
- name: Configure web servers
hosts: webservers
become: true
tasks:
- name: Install httpd
ansible.builtin.dnf:
name: httpd
state: present
- name: Configure database servers
hosts: dbservers
become: true
tasks:
- name: Install mariadb
ansible.builtin.dnf:
name: mariadb-server
state: presentThe playbook above holds two plays. The first play applies to the webservers group, the second to the dbservers group. Plays run top to bottom in the order they’re defined.
Watch out for YAML indentation #
YAML expresses structure through indentation, so a wrong number of spaces breaks the whole file. Following these rules keeps you from losing time on trivial syntax errors during the exam.
- Use spaces only for indentation; never use tabs.
- Align indentation exactly for items at the same level.
- Put one space after a colon (
name: httpd). - Put one space after the
-of a list item.
Writing tasks #
A task is a single module call. Each task defines which module to run, and with which arguments. Think of the modules and options you used in ad-hoc commands carrying straight over into tasks.
tasks:
- name: Install the chrony package
ansible.builtin.dnf:
name: chrony
state: presentThe task above calls the ansible.builtin.dnf module to bring the chrony package into the present state. name is the task’s description, and below it are the module name and its arguments.
The habit of adding a name #
name is optional on a task, but in the hands-on exam it pays to always add one. The name is printed verbatim in the run output, so you can tell at a glance which task changed what. It cuts both debugging time and the time you spend self-checking before grading.
TASK [Install the chrony package] ********************************************
ok: [server1]If you omit name, only the module name shows up in the output, making the task hard to identify. We’ll build the habit of putting a meaningful name on every task.
Prefer FQCN #
Writing a module in the namespace.collection.module form, like ansible.builtin.dnf, is called the FQCN (Fully Qualified Collection Name). Writing just dnf works too, but the FQCN makes clear which collection the module comes from and avoids name collisions, so we recommend it. Writing in FQCN is also the safe choice on the exam.
handler and notify #
After you change a service’s config file, you have to restart the service for the change to take effect. But if the config didn’t change, there’s no need to restart at all. For this pattern of “run something only when a change happened,” Ansible provides the handler.
A handler looks just like an ordinary task, but you define it separately under the play’s handlers section, and it runs only when called via notify.
---
- name: Configure web servers
hosts: webservers
become: true
tasks:
- name: Install httpd
ansible.builtin.dnf:
name: httpd
state: present
- name: Deploy the httpd configuration
ansible.builtin.copy:
src: files/httpd.conf
dest: /etc/httpd/conf/httpd.conf
owner: root
group: root
mode: '0644'
notify: restart httpd
handlers:
- name: restart httpd
ansible.builtin.service:
name: httpd
state: restartedLet’s walk through how the playbook above behaves.
- When the
Deploy the httpd configurationtask actually changes the file (changed), it calls therestart httpdhandler vianotify. - If the file already has the same content and doesn’t change (ok), the handler is not called.
- A called handler doesn’t run right away; it runs only once, after all tasks in the play have finished.
The two core properties of a handler #
To understand handlers precisely, remember two things.
- They run only when there’s a change. The task that fires notify has to be changed for the handler to wake up. On a second run that leaves the same config in place, the task becomes ok, so the handler doesn’t run either.
- They run only once, at the end. Even if several tasks notify the same handler, that handler runs exactly once at the end of the play. This matches the real operational instinct that even if you change config in three places, one service restart is enough.
Thanks to this pattern, you can express “restart the service only when the config changed” without breaking idempotency.
Idempotency #
Idempotency is the property that running the same playbook any number of times guarantees the same result. The first run brings the system to the desired state, and on every subsequent run the system is already there, so nothing changes. The core grading criterion of RHCE is exactly this idempotency.
changed and ok #
A playbook’s run output shows, in color and words, what each task did.
- ok. Means it was already in the desired state, so there was nothing to change.
- changed. Means the task actually changed the system state.
- failed. Means the task failed.
A well-written playbook shows changed on the first run, and on the second run drops to changed=0 with everything ok. If changed keeps appearing on the second run, that task is a sign that idempotency is broken.
When the run finishes, a PLAY RECAP shows a summary at the end.
PLAY RECAP **********************************************************
server1 : ok=3 changed=0 unreachable=0 failed=0When changed=0 shows up on the second run, it means idempotency is guaranteed.
command and shell are not idempotent #
Most modules declare a “desired state,” so they’re automatically idempotent. dnf doesn’t reinstall an already-installed package, and service doesn’t restart an already-running service.
The command and shell modules, by contrast, run the command every time without checking the current state. So they aren’t idempotent, and they always come out as changed even on the second run.
- name: Bad example - runs every time
ansible.builtin.command: useradd appuserOn the second run the task above either errors because the user already exists, or is marked changed every time. Wherever possible, using a dedicated module (user, dnf, service, and so on) instead of command/shell is the right answer.
Patching it with creates and removes #
If there’s no dedicated module and you have no choice but to use command or shell, you can imitate idempotency with the creates and removes options.
- creates. If the specified file already exists, the command is not run.
- removes. If the specified file does not exist, the command is not run.
- name: Run the init script (once only)
ansible.builtin.command: /usr/local/bin/init-db.sh
args:
creates: /var/lib/myapp/initializedThe task above runs the script only when /var/lib/myapp/initialized doesn’t exist, and skips it once the file appears. If you write the script so it creates that file, the task is marked ok from the second run on, keeping idempotency intact.
Running a playbook #
You can run the playbook you wrote in two ways.
# the traditional runner
ansible-playbook site.yml
# the execution-environment-based runner (output similar to the traditional one)
ansible-navigator run site.yml -m stdoutAs we saw in #1, you can use either one depending on the exam environment, so settle on the one you’re comfortable with, but also get familiar with the ansible-navigator run -m stdout option.
Syntax first with –syntax-check #
Quickly checking the YAML syntax and playbook structure before running catches trivial errors ahead of time.
ansible-playbook --syntax-check site.ymlIf there are no errors, only the playbook name is printed. It’s recommended as a light verification step before submitting your answer on the hands-on exam.
Dry run with –check #
--check is a dry-run mode that shows you what would change ahead of time without actually changing the system. It’s useful for vetting a risky change before applying it, or for finding ahead of time any task that would come out changed on a second run.
ansible-playbook --check site.ymlConfirming changes with –diff #
--diff shows the line-by-line difference of how a file changes. Used together with --check, you can confirm exactly which lines would change without applying anything.
ansible-playbook --check --diff site.ymlIt’s especially useful for confirming that a task handling a config file behaves as intended.
A comprehensive example #
Let’s look at one playbook that gathers the elements covered so far. It installs a package, deploys a config file, and restarts the service via a handler only when the config changed.
---
- name: Configure chrony time synchronization
hosts: all
become: true
tasks:
- name: Install the chrony package
ansible.builtin.dnf:
name: chrony
state: present
- name: Deploy the chrony configuration
ansible.builtin.copy:
src: files/chrony.conf
dest: /etc/chrony.conf
owner: root
group: root
mode: '0644'
notify: restart chronyd
- name: Start and enable the chronyd service
ansible.builtin.service:
name: chronyd
state: started
enabled: true
handlers:
- name: restart chronyd
ansible.builtin.service:
name: chronyd
state: restartedHere’s the flow for running this playbook twice to verify idempotency.
# 1) syntax check
ansible-playbook --syntax-check site.yml
# 2) first run - changed appears
ansible-playbook site.yml
# 3) second run - changed=0 is correct
ansible-playbook site.ymlOn the first run, the package install, config deploy, and service start are marked changed, and since the config changed, the restart chronyd handler runs once at the end of the play. On the second run, every task becomes ok, the handler isn’t called, and the PLAY RECAP shows changed=0. Making this two-run verification a habit on every answer is the foundation of passing RHCE.
Exam points #
- A handler wakes only via notify, runs only when there’s a change, and runs once at the end of the play. Get the pattern of firing
notifyon a config-deploy task and restarting the service in a handler into your hands. - Verify idempotency with two runs. We’ll go through the step of confirming
changed=0on the second run via the PLAY RECAP on every answer. - command and shell are not idempotent. Switching to a dedicated module comes first, and if that’s unavoidable, patch it with
creates/removes. - Put a name on every task and use FQCN to secure output readability and accuracy.
- Before submitting, confirm syntax with
--syntax-check, and if needed, vet the changes ahead of time with--check/--diff.
Wrap-up #
What this post locked in:
- A playbook is a list of plays, and a play applies work to a set of hosts via
hosts/become/tasks. - A task is a single module call; for readability and grading checks, always add a
nameand write it in FQCN. - With handler and notify, build the pattern of restarting the service once at the end of the play only when there’s a change.
- Idempotency is RHCE’s core criterion; run twice and confirm
changed=0. command and shell are not idempotent, so patch them with a dedicated module orcreates/removes. - Run with
ansible-playbookoransible-navigator run -m stdout, and vet ahead of time with--syntax-check/--check/--diff.
Next — Variables and facts #
We’ve got the skeleton of a playbook in place. Now we move on to the variables that make the same playbook behave differently per host, and the facts that are gathered automatically from the system.
In #6 Variables and facts: precedence, magic vars, custom facts, we’ll work through the several places to define variables and their precedence, the system information gathered into ansible_facts, magic variables like hostvars/inventory_hostname, and how to add your own custom facts.