Control Ansible Playbook Execution on RHEL

Red Hat Enterprise LinuxBeginner
Practice Now

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 like ansible.builtin.command or ansible.builtin.shell almost always report a "changed" state, even if the command they ran didn't alter the system. changed_when allows 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_when lets 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: yes in case nginx is not running.
  • Install Task: Uses the packages_to_install variable to install both httpd and mod_ssl.
  • Generate Certificate Task: This is a key task. It uses the openssl command to create a self-signed certificate. The args: { 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 uses notify: restart httpd to 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 httpd handler 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.