Chapter 7: Managing Complex Plays and Playbooks

Including and importing files

A playbook that does everything in one file is easy to start and hard to live with. Past a few dozen tasks it is slow to read, risky to change, and impossible to reuse. Ansible lets you split work across files at two levels: whole playbooks, and lists of tasks.

Two levels of reuse

A playbook can pull in other complete playbooks. The result runs as if all their plays were written one after another in a single file.

A play can pull in a file that holds nothing but a list of tasks. The same file can serve many plays.

Select a file to see what is in it and how the pieces connect:

One project, five small files

Importing playbooks

import_playbook is written at the top level of a playbook, where a play would go. It cannot be used inside a play.

---
- name: Web tier
  ansible.builtin.import_playbook: web.yml

- name: Database tier
  ansible.builtin.import_playbook: db.yml

You can mix imported playbooks and ordinary plays in one file; everything runs in the order it is listed.

- name: Prepare the control node
  hosts: localhost
  tasks:
    - ansible.builtin.debug:
        msg: Starting the roll-out

- name: Web tier
  ansible.builtin.import_playbook: web.yml

Each imported file stays a normal playbook that you can also run by itself, which is what makes the split worthwhile: web.yml for a quick change to the web servers, site.yml for everything.

An imported playbook can take vars: as well. The values apply to every play in that file, which lets one generic playbook serve several purposes:

- name: Database tier
  ansible.builtin.import_playbook: install.yml
  vars:
    package: mariadb-server

Task files

A task file is a flat YAML list of tasks. It has no hosts: and no tasks: keyword, because it is not a play. Keep them together in a tasks/ directory next to the playbook, so the structure of a large project stays easy to see:

---
- name: Packages are installed
  ansible.builtin.dnf:
    name: "{{ packages }}"
    state: present

A play brings it in with one of two keywords. Both are used like a module, as a task:

tasks:
  - name: Packages
    ansible.builtin.import_tasks: tasks/install.yml

  - name: Packages
    ansible.builtin.include_tasks: tasks/install.yml

The path is relative to the file that contains the task. Keeping task files in a tasks/ directory next to the playbook is a convention worth following.

Static or dynamic

The two keywords differ in when Ansible reads the file.

Read while the playbook is being parsed, before anything runs. The tasks are copied into the play, as if you had pasted them there.

Read when the play reaches that task, separately for each host. Until then Ansible does not know what is inside.

That one difference explains everything else. Switch the keyword, then add a condition or a loop:

The consequences side by side:

import_tasksinclude_tasks
File is readWhen the playbook is parsedWhen the task runs
when on itCopied to every task in the file; each tests itTested once; if false the file is never read
loop on itNot allowedRuns the file once per item
File name from a variableOnly variables known at parse time (play vars, -e)Any variable, including facts and registered results
--list-tasks and --start-at-taskSee the tasks insideSee only the include itself
A syntax error in the fileFound before the run startsFound when that task is reached

Passing variables

A task file is most useful when it does not hard-code what it works on. Give it variables with vars: on the import or include:

- name: Web servers are configured
  hosts: web
  tasks:
    - name: Packages
      ansible.builtin.import_tasks: tasks/install.yml
      vars:
        packages:
          - httpd
          - firewalld
- name: Database servers are configured
  hosts: db
  tasks:
    - name: Packages
      ansible.builtin.import_tasks: tasks/install.yml
      vars:
        packages:
          - mariadb-server

One file, two plays, two different package lists. Variables set this way exist only for the tasks of that import.

When an include runs in a loop, each pass sees the current item:

- name: Firewall services
  ansible.builtin.include_tasks: tasks/firewall.yml
  loop:
    - http
    - https
TASK [Firewall services] *******************************************************
included: /home/student/project/tasks/firewall.yml for servera.lab.example.com => (item=http)
included: /home/student/project/tasks/firewall.yml for servera.lab.example.com => (item=https)

Where task files pay off

A file name that depends on the host

Because an include is resolved at run time, its file name can come from a fact:

- name: Distribution-specific setup
  ansible.builtin.include_tasks: "tasks/setup-{{ ansible_facts['os_family'] }}.yml"

The same line with import_tasks fails before the play starts, because facts are not gathered yet when the playbook is parsed:

ERROR! Error when evaluating variable in import path: tasks/{{ ansible_facts['os_family'] }}.yml.

The same limit applies to host and group variables from the inventory: they belong to individual hosts, and an import is resolved once for the whole play. A variable in an import path can come from the play, a variables file or the command line (-e), but not from the inventory.

Takeaway

Combine playbooks with import_playbook. Use the chapter lab to check this on a real host.