OnCallReady

Lesson 33.21 · Ansible: Configuration as Code · 21 min read

Roles, collections and project layout

In plain words

A role is a ready-made kit in a labelled box. The "users" box has its instructions (tasks), its spare parts (files and templates), its settings card with sensible defaults, and a note of what other boxes it needs. Anyone who opens a role box knows where everything is, because every box has the same compartments.

Instead of one huge playbook that does everything, a project becomes a few small playbooks that say "web servers get the nginx box and the users box". Collections are boxes of boxes published by others, which you install from Ansible Galaxy at an exact version.

The problem

The playbook from the last lessons installs nginx, templates its config, notifies a handler, sets variables. The database team wants the same nginx setup on their admin host; another project wants it with a different port. Copying fifty lines of tasks between playbooks is how fleets drift apart. Roles package tasks, handlers, templates, files and default variables into one reusable directory with a fixed layout, and collections package modules and roles for others to install.

What you need to know already: the handlers and templates lessons, and the variables lesson (precedence: role defaults are the weakest level).

A role is a directory

roles/nginx/
  tasks/main.yml          the role's tasks (the entry point)
  handlers/main.yml       its handlers
  defaults/main.yml       default variables - meant to be overridden
  vars/main.yml           role variables - hard to override (high precedence)
  templates/              .j2 files, found by template: src: without a path
  files/                  files for copy: src:
  meta/main.yml           metadata and dependencies on other roles
  tests/, README.md

Only tasks/main.yml is needed; Ansible loads whatever else exists. ansible-galaxy role init writes the whole skeleton:

$ cd ~/oncall-lab/labs/1a-ansible/try/roles
$ ansible-galaxy role init roles/motd
- Role roles/motd was created successfully
$ find roles/motd -type f | sort
roles/motd/README.md
roles/motd/defaults/main.yml
roles/motd/handlers/main.yml
roles/motd/meta/main.yml
roles/motd/tasks/main.yml
roles/motd/tests/inventory
roles/motd/tests/test.yml
roles/motd/vars/main.yml

The lab project has a finished role next to it:

$ cat roles/nginx/defaults/main.yml roles/nginx/tasks/main.yml
# the role's knobs: override them in group_vars, host_vars or the play
nginx_port: 80
nginx_server_name: "{{ inventory_hostname }}"
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present

- name: Site config
  ansible.builtin.template:
    src: site.conf.j2
    dest: /etc/nginx/sites-available/default
    mode: "0644"
  notify: Reload nginx

- name: nginx runs and starts at boot
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

Inside a role, template: src: site.conf.j2 means roles/nginx/templates/site.conf.j2, and notify: Reload nginx finds roles/nginx/handlers/main.yml. The role carries everything it needs.

defaults vs vars

defaults/main.yml holds the role's knobs: the lowest precedence of all, so any group_vars, host_vars or play var overrides them. Put every value a user might want to change here, with a sensible default. vars/main.yml is for internal constants (package names per OS, paths) that users should not touch - it beats inventory and play vars, so putting user-facing settings there is a classic way to make them impossible to override.

A convention ansible-lint enforces: prefix a role's variables with the role name (nginx_port, not port), because all variables share one namespace.

Using roles

The roles: section of a play runs roles before the play's own tasks::

- name: Web servers
  hosts: web
  become: true
  roles:
    - nginx                              # roles/nginx next to the playbook
    - role: motd
      vars:
        motd_text: "managed by the platform team"
$ cat site.yml
- name: Web servers
  hosts: web
  become: true
  roles:
    - nginx
$ ansible-playbook site.yml

PLAY [Web servers] *************************************************************

TASK [Gathering Facts] *********************************************************
ok: [web-1]
ok: [web-2]

TASK [nginx : Install nginx] ***************************************************
changed: [web-1]
changed: [web-2]

TASK [nginx : Site config] *****************************************************
changed: [web-1]
changed: [web-2]

TASK [nginx : nginx runs and starts at boot] ***********************************
ok: [web-1]
ok: [web-2]

RUNNING HANDLER [nginx : Reload nginx] *****************************************
changed: [web-1]
changed: [web-2]

PLAY RECAP *********************************************************************
web-1                      : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web-2                      : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Task names show the role: TASK [nginx : Install nginx]. Ansible looks for a role in roles/ next to the playbook, then the roles_path of ansible.cfg (default ~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles), then next to the playbook itself. A typo prints every path it tried:

$ ansible-playbook typo.yml
[ERROR]: the role 'ngnix' was not found in /home/learner/oncall-lab/labs/1a-ansible/try/roles/roles:/home/learner/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:/home/learner/oncall-lab/labs/1a-ansible/try/roles
Origin: typo.yml:5:7

3   become: true
4   roles:
5     - ngnix
        ^ column 7

import vs include

Two ways to pull in more tasks or a role in the middle of a task list:

- ansible.builtin.import_tasks: hardening.yml         # STATIC
- ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"   # DYNAMIC
- ansible.builtin.import_role: { name: nginx }
- ansible.builtin.include_role: { name: nginx }

Rule of thumb: import by default; include when the file depends on a fact or a loop.

Dependencies

meta/main.yml can list roles that must run first:

dependencies:
  - role: common

Handy, and easy to overdo: a long dependency chain makes "what does this play actually run?" hard to answer. Many teams prefer listing roles explicitly in the play.

Collections and Galaxy

A collection is the unit of distribution: modules, roles, plugins under a namespace, like community.general or ansible.posix. You call its content by FQCN: ansible.posix.authorized_key, community.general.ufw. The ansible package ships about 90 of them:

$ ansible-galaxy collection list ansible.posix

# /usr/lib/python3/dist-packages/ansible_collections
Collection                               Version
---------------------------------------- -------
ansible.posix                            2.1.0
$ ansible-doc -l ansible.posix | head -5
ansible.posix.authorized_key Adds or removes an SSH authorized key
ansible.posix.sysctl         Manage entries in sysctl.conf

Ansible Galaxy (galaxy.ansible.com) is the public index. A project pins what it needs in requirements.yml and installs it with one command, so every engineer and the pipeline get the same versions:

# requirements.yml
collections:
  - name: community.general
    version: ">=12.0.0"
roles:
  - name: nginx
    src: https://git.lab/platform/ansible-role-nginx.git
    version: v1.2.0
ansible-galaxy collection install -r requirements.yml     into ~/.ansible/collections
ansible-galaxy role install -r requirements.yml           into ~/.ansible/roles

(This box has no internet: collections come from a lab mirror, roles from git.lab - simulator.)

A project layout that scales

platform-ansible/
  ansible.cfg
  requirements.yml
  inventories/
    prod/hosts.ini            one inventory per environment
    prod/group_vars/all.yml
    prod/group_vars/web.yml
    staging/hosts.ini
    staging/group_vars/...
  site.yml                    imports the other playbooks
  web.yml  db.yml
  roles/
    nginx/  users/  common/

ansible-playbook -i inventories/staging/hosts.ini site.yml and the same playbooks run against staging with staging's variables; -i inventories/prod/hosts.ini for prod. The difference between environments lives in their group_vars, not in copies of the playbooks.

What you can now do

Why it helps

Real Ansible projects are made of roles. Being able to read the standard layout (tasks, handlers, templates, files, defaults, vars, meta) is what lets you find the line that changed a server in someone else's repository in a few minutes.

The defaults-versus-vars distinction is also a frequent source of "my override does not work" bugs, and pinning collections in requirements.yml is what keeps a playbook that worked last month working today.

Commands in this lesson

cd ansible-galaxy find cat ansible-playbook ansible-doc

FAQ

What goes in defaults and what goes in vars?

defaults/main.yml holds the role's knobs: values users of the role are expected to change, at the lowest precedence so any inventory or play can override them. vars/main.yml holds internal values the role relies on, at high precedence, which inventory values cannot override. When in doubt, put it in defaults.

What is the difference between import_role and include_role?

import_ is static: the role's tasks are read when the playbook is parsed, so tags and --list-tasks see them, but a when: applies to every task inside. include_ is dynamic: the role is loaded when the task runs, so it can be in a loop or depend on a registered value, but its tasks are invisible until then.

How do I create a role skeleton?

ansible-galaxy role init roles/users creates the standard directories with main.yml files: tasks, handlers, templates, files, vars, defaults, meta and tests, plus a README. Delete what the role does not need, but keep the names: Ansible finds files by these directory names.

What is a collection?

A package that bundles modules, roles and plugins under a namespace, such as ansible.posix or community.general. You use its modules by their full name (ansible.posix.authorized_key). The ansible package includes many collections; others you install with ansible-galaxy collection install, ideally from a requirements.yml with pinned versions.

Why prefix role variables with the role name?

Variables are global to a play. A role variable called port collides with any other port variable from the inventory or another role, and the stronger one silently wins. Calling it web_port in the web role avoids that. ansible-lint reports var-naming[no-role-prefix] for role variables without the prefix.

In an interview Junior

What is an Ansible role, and how is it laid out?

Roles are reusable units with a fixed directory layout, created with ansible-galaxy role init: tasks/main.yml (the work), handlers/, templates/ and files/, defaults/main.yml (the knobs, lowest precedence, meant to be overridden), vars/main.yml (internal values, high precedence), and meta/main.yml (dependencies and platforms). A play applies them with roles:, or with import_role (static) and include_role (dynamic). A project keeps thin playbooks such as site.yml that map roles to groups, and pins external collections and roles from Ansible Galaxy in requirements.yml so every run uses the same versions.

Also asked: Why would an inventory variable not override a role variable? · What is the difference between a role and a collection? · How do you share a role between several projects?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.