Composing and Ordering Roles

Scenario: A play lists a webapp role before a common role. The web application is configured against a hostname and timezone that common has not set yet.

New words, in plain English

Execution order inside a play is fixed and worth memorising, because a surprising amount of debugging comes down to not knowing it:

  1. pre_tasks
  2. handlers notified by pre_tasks (flushed here)
  3. roles: - in the order listed
  4. tasks:
  5. handlers notified by roles or tasks
  6. post_tasks
  7. handlers notified by post_tasks

So roles always run before the play's own tasks:, no matter where the tasks: block appears in the file. And roles run in listed order, which is why common belongs before webapp.

pre_tasks is the natural home for validation - the assertions that decide whether this run should proceed at all - because it runs before any role has touched anything. post_tasks is the natural home for verification and notification.

For including roles from inside a task list there are two forms. import_role is static: resolved when the playbook is parsed, so its tasks are visible to --list-tasks and tags apply to them individually, but it cannot be looped or chosen by a run-time value. include_role is dynamic: resolved when reached, so it can be looped or selected conditionally, at the cost of being invisible until it runs.

Analogy: The play is a construction schedule. Foundations before walls, walls before wiring. pre_tasks is the site inspection that can call the whole thing off before a single brick is laid.

A worked example

- name: Configure the web tier
  hosts: webservers

  pre_tasks:
    - name: Refuse to run without a valid release
      ansible.builtin.assert:
        that:
          - app_version is defined
          - app_version is match('^\d+\.\d+\.\d+
  

)
        fail_msg: "app_version must be x.y.z, got '{{ app_version | default('') }}'"
      run_once: true

  roles:
    - common          # runs first
    - webapp          # depends on what common established

  tasks:
    - name: Runs AFTER both roles, wherever this block appears
      ansible.builtin.debug:
        msg: "configured"

  post_tasks:
    - name: Verify the service answers
      ansible.builtin.uri:
        url: "http://localhost:{{ webapp_port }}/healthz"

A dynamic include_role inside a loop is the clean way to apply the same role several times with different parameters - one per application instance, for example. Remember that tags behave differently: a tag on an include_role controls whether the include itself happens, whereas a tag on import_role is applied to every task it pulls in.

When a play grows past a handful of roles, resist the urge to encode ordering through meta/dependencies. Explicit ordering in the play is visible to whoever is reading it at 3 AM; a dependency graph buried in metadata is not.

Warning: Roles always execute before the play's tasks: section, regardless of where tasks: sits in the file. Putting tasks above roles: in the YAML does not make them run first.
Goal: Put this to work in the ansible-role-refactor lab. Open /labs/ansible, pick ansible-role-refactor, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.