Red Hat Certified Engineer (RHCE) #11: Writing and Using Roles

9 min read

If #10 Ansible Vault had you encrypting secrets to handle them safely, this time we cover the role that bundles a playbook into a reusable unit. A role groups tasks, handlers, variables, templates, and files into a fixed directory structure so that a configuration written once can be called from many playbooks. The “write a role and call it from a playbook” task type comes up on almost every RHCE exam, so in this post we’ll get the structure and usage into your hands.

Why you need roles #

The playbooks we’ve written so far have had their tasks, handlers, variables, and templates scattered across one file or a few files. For small configurations that’s fine, but if you want to reuse the same bundle of package installs, config deployments, and service startups in another playbook, you have to copy it every time. The more copied code piles up, the harder it is to maintain — fixing one spot means hunting down and fixing every copy, and inconsistencies creep in fast.

A role organizes this bundle once into an agreed-upon directory structure, and the playbook then calls it just by name. You gain the following advantages.

  • Reuse. A role written once is called from many playbooks.
  • Structure. Tasks, handlers, variables, and files go in fixed locations, so they’re easy to find.
  • Sharing. You can pull them down with ansible-galaxy or package them in a collection to distribute.

The role directory structure #

A role follows fixed subdirectory names. The main.yml in each directory is the entry point that loads automatically. The standard structure is as follows.

Standard role directory layout
roles/
└── webserver/
    ├── tasks/
    │   └── main.yml        # tasks the role performs
    ├── handlers/
    │   └── main.yml        # handlers called by notify
    ├── defaults/
    │   └── main.yml        # default variables (lowest precedence)
    ├── vars/
    │   └── main.yml        # role variables (higher precedence)
    ├── templates/
    │   └── httpd.conf.j2   # the src base path for the template module
    ├── files/
    │   └── index.html      # the src base path for the copy module
    ├── meta/
    │   └── main.yml        # dependencies and metadata
    └── README.md

The key point here is automatic path lookup. If you write just src: httpd.conf.j2 for the template module inside a role’s task, Ansible automatically looks for it under that role’s templates/. The src of the copy module likewise looks under files/. So inside a role you don’t have to write the full path.

Scaffolding a role with ansible-galaxy #

You don’t need to create the empty directories by hand. The ansible-galaxy role init command generates the standard structure in one shot.

Generate the role skeleton
# run inside the roles directory
$ cd roles
$ ansible-galaxy role init webserver
- Role webserver was created successfully

$ tree webserver
webserver/
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── README.md
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

Each generated main.yml is an empty skeleton, so you only fill in the directories you need. On the exam, scaffolding with this command and then writing just tasks/main.yml plus the variables and templates you need is the fast flow.

Writing the role contents #

Using the webserver role as an example, we’ll fill in tasks, handlers, defaults, and a template.

tasks/main.yml #

roles/webserver/tasks/main.yml
---
# roles/webserver/tasks/main.yml
- name: Install the httpd package
  ansible.builtin.dnf:
    name: httpd
    state: present

- name: Deploy index.html
  ansible.builtin.copy:
    src: index.html          # automatically finds files/index.html
    dest: /var/www/html/index.html

- name: Deploy the httpd config
  ansible.builtin.template:
    src: httpd.conf.j2       # automatically finds templates/httpd.conf.j2
    dest: /etc/httpd/conf/httpd.conf
  notify: restart httpd

- name: Start the httpd service
  ansible.builtin.service:
    name: httpd
    state: started
    enabled: true

handlers/main.yml #

roles/webserver/handlers/main.yml
---
# roles/webserver/handlers/main.yml
- name: restart httpd
  ansible.builtin.service:
    name: httpd
    state: restarted

defaults/main.yml #

roles/webserver/defaults/main.yml
---
# roles/webserver/defaults/main.yml
http_port: 80
server_admin: admin@example.com

Variables placed in defaults have the lowest precedence, so when you call the role from a playbook and pass a value of the same name, you can override them easily. By convention, values that the role’s caller may want to adjust go in defaults, not vars.

Using a role #

There are three main ways to call a role you’ve written from a playbook: the roles key, include_role, and import_role — and they behave differently.

The roles key #

The simplest way is to list it under the play-level roles key. The role’s tasks run before the play’s regular tasks.

site.yml
---
# site.yml
- name: Configure the web server
  hosts: web
  become: true
  roles:
    - webserver

To pass variables, you write it like this.

Pass variables to the role
  roles:
    - role: webserver
      vars:
        http_port: 8080

include_role and import_role #

If you call a role from within the task list instead of the roles key, you can directly control where it runs. Here the difference between the two modules is an exam point.

Itemimport_roleinclude_role
Processing timeStatic. Included ahead of time during playbook parsingDynamic. Included at run time
when behaviorThe condition applies to each internal task individuallyThe condition applies once to the role call
loop useNot allowedAllowed. Calls the role repeatedly
tag inheritanceInternal tasks inherit the tagThe tag applies only to the calling task
Dynamic call with include_role
---
- name: Call the role conditionally
  hosts: web
  become: true
  tasks:
    - name: Run a regular task first
      ansible.builtin.debug:
        msg: "before the role call"

    - name: Call the webserver role dynamically
      ansible.builtin.include_role:
        name: webserver
      when: enable_web | default(false)

To sum up, use import_role when you can include it statically ahead of time, and use include_role when you need to decide at run time whether or how many times it runs via a condition or loop.

Role variable precedence #

A role can place variables in two locations, defaults and vars, and the two have different precedence. Within the full precedence covered in #6 Variables and facts, narrowing down to just the role-related positions gives the following.

  • defaults/main.yml. The lowest precedence. Almost every other variable overrides it.
  • The value passed via vars: at role-call time. Overrides defaults.
  • vars/main.yml. Higher than defaults.
  • Play-level and caller-side vars, inventory variables, extra vars (-e). Override from higher positions.

The key is that defaults is the lowest. Put values that the role’s user should be able to change in defaults, and values that must stay fixed inside the role in vars. If you put the same variable in both places at once, vars wins, which makes it hard to override from outside — so take care.

Role dependencies #

If one role needs another role to run first, declare it in dependencies in meta/main.yml. A declared dependent role runs before the current role.

roles/webserver/meta/main.yml
---
# roles/webserver/meta/main.yml
dependencies:
  - role: common              # runs before webserver
  - role: firewall
    vars:
      firewall_ports:
        - 80/tcp

Since the common and firewall roles run ahead of webserver, you can bundle prerequisite work like installing common packages or opening the firewall as dependencies. You can also pass values to dependencies via vars, as above.

roles_path: where roles are found #

Ansible looks for roles in the following order.

  1. The roles/ directory in the same location as the playbook
  2. The paths specified in roles_path in ansible.cfg

So if you place a roles/ directory next to the playbook, the role is recognized with no extra configuration. To put roles elsewhere, write the path in ansible.cfg.

ansible.cfg
# ansible.cfg
[defaults]
roles_path = ./roles:/etc/ansible/roles

On the exam, creating roles under the working directory’s roles/ is usually the safest. Roles pulled down with ansible-galaxy also follow this path rule.

Full example: writing a role then calling it #

Let’s tie the pieces we’ve built so far into one flow. The working directory structure is as follows.

Project directory layout
project/
├── ansible.cfg
├── inventory
├── site.yml
└── roles/
    └── webserver/
        ├── tasks/main.yml
        ├── handlers/main.yml
        ├── defaults/main.yml
        ├── templates/httpd.conf.j2
        └── files/index.html

The calling playbook is short, like this. Since the configuration is gathered inside the role, the playbook body just calls the role.

site.yml
---
# site.yml
- name: Configure the web server group
  hosts: web
  become: true
  roles:
    - role: webserver
      vars:
        http_port: 8080
        server_admin: web@example.com

For the run and the idempotency check, as usual you run it twice and see whether changed becomes 0 on the second run.

Run and verify idempotence
$ ansible-navigator run site.yml -m stdout
$ ansible-navigator run site.yml -m stdout   # second run: changed=0 means idempotent

Exam points #

  • Generate the standard directory structure first with ansible-galaxy role init <name>.
  • The role directories are tasks, handlers, defaults, vars, templates, files, and meta, and each main.yml is the automatic entry point.
  • Inside a role, the src of template automatically finds templates/ and the src of copy finds files/, so you don’t write the full path.
  • The roles key runs before regular tasks. If you need dynamic calls via condition or loop, use include_role; for static inclusion, use import_role.
  • In role variable precedence, defaults is the lowest. Put values to be overridden from outside in defaults, and fixed values in vars.
  • Declare ordering between roles via dependencies in meta/main.yml.
  • Roles are found in roles/ next to the playbook and in roles_path of ansible.cfg. The working directory’s roles/ is the safest.

Wrap-up #

What this post locked in:

  • A role is a unit that bundles tasks, handlers, variables, templates, and files into a standard directory for reuse.
  • Scaffold a skeleton with ansible-galaxy role init and fill in only the directories you need.
  • Calling is done via the roles key, import_role (static), and include_role (dynamic), and they behave differently.
  • defaults has the lowest precedence, so put default values there that are easy to override from outside.
  • Handle dependencies via dependencies in meta/main.yml and the lookup path via roles_path.

Next — Collections #

Once you’ve modularized your configuration with roles, the next step is the collection — the unit that bundles multiple roles plus modules and plugins for distribution.

In #12 Collections: Galaxy, Automation Hub, we’ll cover the structure of a collection, ansible-galaxy collection install, installing dependent collections with requirements.yml, the difference between Ansible Galaxy and Automation Hub, and the habit of referencing modules precisely with FQCNs (the ansible.builtin.dnf form).

X