Introduction
In this lab, you will learn how to control the execution flow of Ansible playbooks on a Red Hat Enterprise Linux (RHEL) system. You will start by writing a playbook that utilizes fundamental control structures, including loops to repeat tasks efficiently and conditionals to run tasks only when specific criteria are met. You will also implement handlers to trigger actions, such as service restarts, only when a change occurs, making your automation more intelligent and efficient.
Building on these foundational skills, you will explore more advanced techniques for managing playbook execution. This includes using block and rescue statements to handle task failures gracefully and employing changed_when and failed_when to gain fine-grained control over task status. To conclude the lab, you will apply all these concepts in a practical exercise to deploy a secure web server, solidifying your ability to create robust and reliable Ansible automation.
Write a Playbook with Loops and Conditionals
In this step, you will learn two fundamental concepts in Ansible for controlling task execution: loops and conditionals. Loops allow you to repeat a task multiple times with different values, which is highly efficient for tasks like installing multiple packages or creating multiple users. Conditionals, using the when keyword, allow you to run a task only when specific criteria are met, such as the operating system being a particular version or a file already existing.
First, let's ensure Ansible is installed on your LabEx VM. We will use the DNF package manager for this.
sudo dnf install -y ansible-core
You should see output indicating that ansible-core and its dependencies are being installed.
...
Installed:
ansible-core-2.x.x-1.el9.x86_64
...
Complete!
Now, let's set up our project directory. All our work for this lab will be inside a dedicated directory to keep things organized.
cd ~/project
mkdir control-flow-lab
cd control-flow-lab
An Ansible project needs an inventory file, which defines the hosts you want to manage. For this lab, we will manage the local machine, localhost.
Create an inventory file named inventory using the nano editor:
nano inventory
Add the following line to the file. This tells Ansible to run the playbook on localhost and to connect to it directly instead of using SSH.
localhost ansible_connection=local
Save the file and exit nano by pressing Ctrl+X, then Y, and Enter.
Next, we will create our first playbook, playbook.yml, to demonstrate a loop. This playbook will install a list of useful command-line tools.
nano playbook.yml
Enter the following YAML content into the editor. This playbook defines one task that uses the ansible.builtin.dnf module to install packages. The become: yes directive tells Ansible to execute tasks with sudo privileges, which is necessary for installing packages. The loop keyword provides a list of package names. Ansible will run this task once for each item in the list, substituting the {{ item }} placeholder with the current package name.
---
- name: Install common tools
hosts: localhost
become: yes
tasks:
- name: Install specified packages
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- git
- tree
- wget
Save and exit the editor. Now, run the playbook using the ansible-playbook command, specifying your inventory file with the -i flag.
ansible-playbook -i inventory playbook.yml
The output will show the playbook execution. Ansible will check each package and install it if it's not already present. The PLAY RECAP at the end summarizes the results.
PLAY [Install tools and run conditional tasks] *********************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Install specified packages] **********************************************
changed: [localhost] => (item=git)
changed: [localhost] => (item=tree)
changed: [localhost] => (item=wget)
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Now, let's modify the playbook to include a conditional task. We will add a task that prints a message, but only if the operating system is Red Hat Enterprise Linux. This is a common use case for tailoring automation to specific environments.
Open the playbook.yml file again:
nano playbook.yml
Add the following tasks to the end of the file. The when keyword evaluates the given expression. ansible_facts['distribution'] is a variable that Ansible automatically discovers about the managed host. The first task will run because our environment is RHEL, and the second task will be skipped.
---
- name: Install tools and run conditional tasks
hosts: localhost
become: yes
tasks:
- name: Install specified packages
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- git
- tree
- wget
- name: Show message on Red Hat systems
ansible.builtin.debug:
msg: "This system is a Red Hat family distribution."
when: ansible_facts['distribution'] == "RedHat"
- name: Show message on other systems
ansible.builtin.debug:
msg: "This system is NOT a Red Hat family distribution."
when: ansible_facts['distribution'] != "RedHat"
Save and exit the editor. Run the updated playbook:
ansible-playbook -i inventory playbook.yml
Observe the output carefully. The package installation task will likely report ok for all items since they are already installed. More importantly, you will see the first debug message printed, while the second one is marked as skipping.
PLAY [Install tools and run conditional tasks] *********************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Install specified packages] **********************************************
ok: [localhost] => (item=git)
ok: [localhost] => (item=tree)
ok: [localhost] => (item=wget)
TASK [Show message on Red Hat systems] *****************************************
ok: [localhost] => {
"msg": "This system is a Red Hat family distribution."
}
TASK [Show message on other systems] *******************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
You have successfully written and executed an Ansible playbook that uses both loops to perform repetitive actions and conditionals to control task execution based on system facts.
Implement Handlers to Trigger Service Restarts
In this step, you will learn about Ansible handlers. Handlers are special tasks that only run when "notified" by another task. They are typically used for actions that should only occur when a change has been made, such as restarting a service after its configuration file has been updated. This approach is more efficient than restarting a service on every playbook run, as it ensures the action is only taken when necessary.
We will build a playbook that installs the Nginx web server, deploys a custom homepage, and uses a handler to reload Nginx only when the homepage content changes.
First, let's create a new directory for this exercise to keep our project organized.
cd ~/project
mkdir control-handlers-lab
cd control-handlers-lab
As before, we need an inventory file to tell Ansible where to run the playbook.
nano inventory
Add the following line to specify the local machine.
localhost ansible_connection=local
Save and exit the editor (Ctrl+X, Y, Enter).
Next, we need a file to serve as our web server's homepage. We'll create a files directory to store it.
mkdir files
Now, create a simple index.html file inside the files directory.
nano files/index.html
Add the following HTML content:
<h1>Welcome to the Ansible Handler Lab!</h1>
Save and exit the editor.
Now, you will create the playbook deploy_nginx.yml. This playbook will perform three main actions: install Nginx, copy the index.html file, and define a handler to reload Nginx.
nano deploy_nginx.yml
Enter the following content. Pay close attention to the notify keyword in the "Copy homepage" task and the corresponding handlers section at the end. The become: yes directive tells Ansible to execute tasks with sudo privileges, which is necessary for installing packages and managing services.
---
- name: Deploy Nginx with a handler
hosts: localhost
become: yes
tasks:
- name: Ensure Nginx is installed
ansible.builtin.dnf:
name: nginx
state: present
- name: Start and enable Nginx service
ansible.builtin.systemd:
name: nginx
state: started
enabled: yes
- name: Copy homepage
ansible.builtin.copy:
src: files/index.html
dest: /usr/share/nginx/html/index.html
notify: reload nginx
handlers:
- name: reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
Save and exit the editor.
Now, run the playbook for the first time.
ansible-playbook -i inventory deploy_nginx.yml
You will see output showing that Nginx was installed (or was already present), the Nginx service was started and enabled, the index.html file was copied (status changed), and importantly, the handler was notified and executed at the end of the play.
...
TASK [Copy homepage] ***********************************************************
changed: [localhost]
RUNNING HANDLER [reload nginx] *************************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
You can verify that the web server is running and serving your custom page using curl.
curl http://localhost
The output should be the content of your index.html file.
<h1>Welcome to the Ansible Handler Lab!</h1>
Now, run the exact same playbook again without making any changes.
ansible-playbook -i inventory deploy_nginx.yml
This time, observe the output. The "Copy homepage" task will report ok instead of changed because the file on the destination already matches the source. The "Start and enable Nginx service" task will also report ok since the service is already running and enabled. Because no tasks notified the handler, the handler was not run.
...
TASK [Copy homepage] ***********************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
To see the handler in action again, let's modify the source index.html file.
nano files/index.html
Change the content to the following:
<h1>The Handler Ran Again!</h1>
Save and exit. Now, run the playbook one more time.
ansible-playbook -i inventory deploy_nginx.yml
Because the source file changed, the "Copy homepage" task will again report changed, which in turn notifies and runs the reload nginx handler.
...
TASK [Copy homepage] ***********************************************************
changed: [localhost]
RUNNING HANDLER [reload nginx] *************************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Verify the change with curl one last time.
curl http://localhost
You should see the updated message.
<h1>The Handler Ran Again!</h1>
This exercise demonstrates the power and efficiency of handlers for managing service state in response to configuration changes.
Manage Task Failures with Block and Rescue
In this step, you will learn how to gracefully handle errors in your Ansible playbooks. By default, if any task fails, Ansible stops executing the entire playbook on that host. While this is a safe default, sometimes you need more control. You will explore two methods for error handling: the simple ignore_errors directive and the more powerful block, rescue, and always structure, which provides a way to attempt tasks and define recovery actions if they fail.
First, let's create a new directory for this exercise.
cd ~/project
mkdir control-errors-lab
cd control-errors-lab
Create the standard inventory file for localhost.
nano inventory
Add the following content:
localhost ansible_connection=local
Save and exit the editor (Ctrl+X, Y, Enter).
Now, let's create a playbook named playbook.yml that is designed to fail. The first task will attempt to install a package that does not exist.
nano playbook.yml
Enter the following content. This playbook tries to install a fake package httpd-fake and then a real package, mariadb-server.
---
- name: Demonstrate Task Failure
hosts: localhost
become: yes
tasks:
- name: Attempt to install a non-existent package
ansible.builtin.dnf:
name: httpd-fake
state: present
- name: Install MariaDB server
ansible.builtin.dnf:
name: mariadb-server
state: present
Save and exit the editor. Now, run the playbook.
ansible-playbook -i inventory playbook.yml
You will see the first task fail with an error message because the httpd-fake package cannot be found. Crucially, Ansible will stop, and the second task, "Install MariaDB server," will not be executed.
...
TASK [Attempt to install a non-existent package] *******************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "No match for argument: httpd-fake", "rc": 1, "results": []}
PLAY RECAP *********************************************************************
localhost : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
Now, let's use block and rescue to handle this failure more elegantly. The block keyword groups a set of tasks. If any task within the block fails, Ansible skips the rest of the tasks in the block and executes the tasks in the rescue section. The always section will run regardless of whether the block or rescue sections succeeded or failed.
Modify playbook.yml to use this structure.
nano playbook.yml
Replace the entire content with the following. Here, we try to install the fake package in the block. When it fails, the rescue section will run, installing mariadb-server as a recovery step. The always section will print a message at the end.
---
- name: Handle Task Failure with Block and Rescue
hosts: localhost
become: yes
tasks:
- name: Attempt primary task, with recovery
block:
- name: Attempt to install a non-existent package
ansible.builtin.dnf:
name: httpd-fake
state: present
- name: This task will be skipped
ansible.builtin.debug:
msg: "This message will not appear because the previous task fails."
rescue:
- name: Install MariaDB server on failure
ansible.builtin.dnf:
name: mariadb-server
state: present
always:
- name: This always runs
ansible.builtin.debug:
msg: "The block has completed, either by success or rescue."
Save and exit. Run the playbook again.
ansible-playbook -i inventory playbook.yml
Observe the output. The first task in the block fails as expected. The second task in the block is skipped. Ansible then moves to the rescue section and successfully installs mariadb-server. Finally, the always section runs.
...
TASK [Attempt to install a non-existent package] *******************************
fatal: [localhost]: FAILED! => ...
TASK [This task will be skipped] ***********************************************
skipping: [localhost]
RESCUE START *******************************************************************
TASK [Install MariaDB server on failure] ***************************************
changed: [localhost]
ALWAYS START *******************************************************************
TASK [This always runs] ********************************************************
ok: [localhost] => {
"msg": "The block has completed, either by success or rescue."
}
PLAY RECAP *********************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=1 rescued=1 ignored=0
Now, let's see what happens when the block succeeds. Edit the playbook and fix the package name.
nano playbook.yml
Change httpd-fake to a real package, httpd.
## ... (rest of the playbook)
block:
- name: Attempt to install a valid package
ansible.builtin.dnf:
name: httpd ## Corrected from httpd-fake
state: present
- name: This task will now run
ansible.builtin.debug:
msg: "This message will now appear because the previous task succeeds."
## ... (rest of the playbook)
Save and exit. Run the playbook one last time.
ansible-playbook -i inventory playbook.yml
This time, both tasks in the block succeed. Because the block completed without errors, the rescue section is skipped entirely. The always section still runs, as its name implies.
...
TASK [Attempt to install a valid package] **************************************
changed: [localhost]
TASK [This task will now run] **************************************************
ok: [localhost] => {
"msg": "This message will now appear because the previous task succeeds."
}
RESCUE START *******************************************************************
skipping rescue
ALWAYS START *******************************************************************
TASK [This always runs] ********************************************************
ok: [localhost] => {
"msg": "The block has completed, either by success or rescue."
}
PLAY RECAP *********************************************************************
localhost : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
You have now successfully used the block/rescue/always structure to create a robust playbook that can handle failures and perform recovery actions.
Control Task State with changed_when and failed_when
In this step, you will gain finer control over how Ansible interprets the outcome of your tasks. You'll learn about two powerful directives: changed_when and failed_when.
changed_when: By default, modules likeansible.builtin.commandoransible.builtin.shellalmost always report a "changed" state, even if the command they ran didn't alter the system.changed_whenallows you to define a custom condition that determines if a task should be reported as "changed". This is crucial for writing idempotent playbooks and for accurately triggering handlers.failed_when: Sometimes, a command might exit with a non-zero status code (which Ansible considers a failure) even when the outcome is acceptable.failed_whenlets you override the default failure conditions, allowing your playbook to continue based on more intelligent criteria, such as the command's output or a specific exit code.
Let's begin by setting up a new project directory.
cd ~/project
mkdir control-state-lab
cd control-state-lab
Create the standard inventory file for localhost.
nano inventory
Add the following content:
localhost ansible_connection=local
Save and exit the editor (Ctrl+X, Y, Enter).
Using changed_when
First, let's see how a command task behaves by default. We'll create a playbook that runs the date command. This command simply prints the date and does not change the system, but the command module will report it as a change.
Create a new playbook named playbook.yml.
nano playbook.yml
Enter the following content:
---
- name: Control Task State
hosts: localhost
tasks:
- name: Check local time (default behavior)
ansible.builtin.command: date
Save and exit. Now, run the playbook.
ansible-playbook -i inventory playbook.yml
Notice in the output that the task is reported as changed=1, even though nothing on the system was modified.
...
TASK [Check local time (default behavior)] *************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 ...
Now, let's use changed_when to tell Ansible that this command never changes the system. Modify playbook.yml.
nano playbook.yml
Add changed_when: false to the task.
---
- name: Control Task State
hosts: localhost
tasks:
- name: Check local time (with changed_when)
ansible.builtin.command: date
changed_when: false
Save and exit. Run the playbook again.
ansible-playbook -i inventory playbook.yml
This time, the task reports ok and the final recap shows changed=0. You have successfully overridden the default behavior.
...
TASK [Check local time (with changed_when)] ************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 ...
Using failed_when
Next, let's explore failed_when. We'll create a task that checks for the existence of a file that isn't there. The command will "fail" by default.
First, create a dummy file to search within.
echo "System is running" > status.txt
Now, modify playbook.yml to search for the word "ERROR" in this file. The grep command will exit with a status code of 1 because the word is not found, which Ansible interprets as a failure.
nano playbook.yml
Replace the content with the following:
---
- name: Control Task State
hosts: localhost
tasks:
- name: Check for ERROR in status file (will fail)
ansible.builtin.command: grep ERROR status.txt
Save and exit. Run the playbook.
ansible-playbook -i inventory playbook.yml
As expected, the playbook execution stops with a FAILED! message.
...
TASK [Check for ERROR in status file (will fail)] ******************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["grep", "ERROR", "status.txt"], "delta": "...", "end": "...", "msg": "non-zero return code", "rc": 1, ...}
...
This is not what we want. The absence of "ERROR" is a success condition for us. We can use failed_when to redefine what constitutes a failure. We'll tell Ansible to only fail if the command's return code is greater than 1. A return code of 1 (pattern not found) will now be considered a success. We also need to register the task's result to inspect its return code (rc).
Modify playbook.yml one last time.
nano playbook.yml
Update the playbook with register and failed_when.
---
- name: Control Task State
hosts: localhost
tasks:
- name: Check for ERROR in status file (with failed_when)
ansible.builtin.command: grep ERROR status.txt
register: grep_result
failed_when: grep_result.rc > 1
changed_when: false
We also added changed_when: false because grep is a read-only operation and doesn't change the system.
Save and exit. Run the final playbook.
ansible-playbook -i inventory playbook.yml
Success! The task now reports ok because its return code was 1, which does not meet our new failure condition (rc > 1). The playbook completes successfully.
...
TASK [Check for ERROR in status file (with failed_when)] ***********************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 ...
You have now learned how to use changed_when and failed_when to precisely define the success, changed, and failure states of your tasks, leading to more robust and intelligent automation.
Deploy a Secure Web Server Using Task Control
In this final step, you will combine all the concepts you've learned—loops, conditionals, handlers, and error handling—to build a single, robust playbook. The goal is to deploy the Apache web server (httpd), secure it with mod_ssl, generate a self-signed SSL certificate, and deploy a custom homepage. This practical exercise mirrors a real-world automation task.
First, let's set up the project directory for this capstone exercise.
cd ~/project
mkdir control-review-lab
cd control-review-lab
As always, create an inventory file to define your target host.
nano inventory
Add the localhost entry:
localhost ansible_connection=local
Save and exit the editor (Ctrl+X, Y, Enter).
Next, we need a directory to store the files our playbook will deploy.
mkdir files
Now, create a custom homepage, index.html, inside the files directory.
nano files/index.html
Add the following HTML content. This will be the page served by our secure web server.
<h1>Secure Web Server Deployed by Ansible!</h1>
<p>This page is served over HTTPS.</p>
Save and exit the editor.
Now it's time to build the main playbook, deploy_secure_web.yml. This playbook will be more complex than the previous ones, integrating multiple concepts.
nano deploy_secure_web.yml
Enter the following complete playbook. Read the comments within the code to understand how each part contributes to the overall goal.
---
- name: Deploy a Secure Apache Web Server
hosts: localhost
become: yes
vars:
packages_to_install:
- httpd
- mod_ssl
ssl_cert_path: /etc/pki/tls/certs/localhost.crt
ssl_key_path: /etc/pki/tls/private/localhost.key
tasks:
- name: Stop nginx to free port 80
ansible.builtin.systemd:
name: nginx
state: stopped
ignore_errors: yes
- name: Install httpd and mod_ssl packages
ansible.builtin.dnf:
name: "{{ packages_to_install }}"
state: present
- name: Generate self-signed SSL certificate if it does not exist
ansible.builtin.command: >
openssl req -new -nodes -x509
-subj "/C=US/ST=None/L=None/O=LabEx/CN=localhost"
-keyout {{ ssl_key_path }}
-out {{ ssl_cert_path }}
args:
creates: "{{ ssl_cert_path }}"
- name: Deploy custom index.html
ansible.builtin.copy:
src: files/index.html
dest: /var/www/html/index.html
notify: restart httpd
- name: Start and enable httpd service
ansible.builtin.systemd:
name: httpd
state: started
enabled: yes
handlers:
- name: restart httpd
ansible.builtin.systemd:
name: httpd
state: restarted
Let's break down what this playbook does:
vars: Defines variables for the packages to install and the paths for the SSL certificate and key, making the playbook easier to read and maintain.- Stop Nginx Task: Stops the nginx service from the previous lab step to free up port 80 for Apache. Uses
ignore_errors: yesin case nginx is not running. - Install Task: Uses the
packages_to_installvariable to install bothhttpdandmod_ssl. - Generate Certificate Task: This is a key task. It uses the
opensslcommand to create a self-signed certificate. Theargs: { creates: ... }directive makes this task idempotent. The command will only run if the certificate file (/etc/pki/tls/certs/localhost.crt) does not already exist. - Deploy Homepage Task: Copies your custom
index.html. Crucially, it usesnotify: restart httpdto trigger the handler if the file is changed. - Start Service Task: Uses the systemd module to start and enable the httpd service after all configuration is in place, ensuring it starts on boot.
- Handler: The
restart httpdhandler performs a restart of Apache using systemd, which is only triggered when a configuration or content file changes.
Save and exit the editor. Now, execute your comprehensive playbook.
ansible-playbook -i inventory deploy_secure_web.yml
On the first run, you should see several tasks reporting changed, including stopping nginx, package installation, certificate generation, file copy, and service start.
...
TASK [Start and enable httpd service] ******************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=6 changed=5 unreachable=0 failed=0 ...
Finally, verify that your secure web server is working. First test the HTTP version, then the HTTPS version with the -k flag to ignore warnings about the self-signed certificate.
curl http://localhost
You should see the content of your custom homepage.
<h1>Secure Web Server Deployed by Ansible!</h1>
<p>This page is served over HTTPS.</p>
You can also test the HTTPS version:
curl -k https://localhost
If you run the playbook again, you will see that no tasks report changed, and the handler is not run, proving your playbook is idempotent.
Congratulations! You have successfully built a practical, robust Ansible playbook that combines loops, variables, idempotent command execution, and handlers to deploy a secure application.
Summary
In this lab, you learned to control Ansible playbook execution on a RHEL system. You started by setting up a basic project environment, including installing Ansible and creating an inventory file. You then explored fundamental control flow structures, using loops to efficiently repeat tasks with different inputs and conditionals with the when statement to run tasks only under specific circumstances. Building on this, you implemented handlers to create responsive automations, such as triggering a service restart only when its configuration file has been modified.
The lab also covered advanced techniques for managing playbook execution. You learned how to build more robust playbooks by using block and rescue clauses to handle task failures gracefully. Furthermore, you gained fine-grained control over task outcomes by using changed_when and failed_when to define custom success and failure conditions. Finally, you consolidated all these skills by applying them to a practical scenario: deploying a secure web server, demonstrating how to effectively combine loops, conditionals, handlers, and error handling in a real-world automation workflow.



