Nunc Fluens

"Nunc fluens facit tempus, nunc stans facit aeternitatem." - Boethius, _The Consolation of Philosophy_
"The value of philosophy is, in fact, to be sought largely in its very uncertainty . . . it keeps alive our sense of wonder by showing familiar things in an unfamiliar aspect." - Bertrand Russell, _The Problems of Philosophy_

How to include vars and tasks in Ansible

The following is based on the latest version of Ansible 2.9 (2.9.9) as of June 15, 2020.

Intro

In the Ansible System Roles project, the role code uses include_vars and include_task to include vars and tasks files which depend on the platform and version. For example, you may have a role which manages a system service, and most of the code is platform independent, but the list of packages to pass to the package module, and the name of the service to manage with the service module, are platform specific. We use an idiom like this in our role tasks/main.yml:

# 
- name: Set version specific variables
  include_vars: "{{ item }}"
  with_first_found:
    - "{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
    - "{{ ansible_distribution }}_{{ ansible_distribution_major_version }}.yml"
    - "{{ ansible_distribution }}.yml"
    - "{{ ansible_os_family }}.yml"
    - "default.yml"

- name: Ensure role packages
  package:
    name: "{{ __rolename_packages }}"
    state: present

- name: Ensure role services are enabled and running
  service:
    name: "{{ item }}"
    state: started
    enabled: yes
  loop: "{{ __rolename_services }}"
# 

This uses the Ansible first_found lookup plugin in the form of a with_first_found loop to load the first matching variables file, where the order of the lookup is from most platform/version specific to least. That is, include the file that most specifically defines the variables used by this role for the platform and version. The file vars/main.yml is included by default when the role is included, and contains platform independent variables, or variables with default values. The files such as vars/RedHat.yml, vars/Fedora.yml, vars/RedHat_8.yml, etc. contain variable definitions specific to that platform/version. That allows use to define variables such as __rolename_packages and __rolename_services to be platform dependent.

Problems

One problem is that you may have to duplicate some definitions. For example, if you have some definitions that are the same in both vars/CentOS_8.yml and vars/RedHat_8.yml, you will have to define everything in both files. In some cases, you can symlink one file to the other.

Another problem is when you have multiple roles that use the above idiom that include each other, and not every role defines every platform/version specific file. What can happen is that when one role includes another role, the lookup path ansible_search_path will contain the path for both roles. In this example, role_a includes role_b.

# 
- name: in role_a tasks/main.yml
  debug:
    msg: in role_a tasks/main.yml

- name: show ansible_search_path before
  debug:
    msg: ansible_search_path before {{ ansible_search_path }}
# 

At this point, ansible_search_path will contain ['/base/roles/role_a','/base/roles/role_a/tasks']. Now, include role_b:

# 
- name: include role_b
  include_role:
    name: role_b
# 

Where role_b looks like this:

# 
- name: show ansible_search_path in role_b
  debug:
    msg: ansible_search_path role_b {{ ansible_search_path }}
# 

At this point, ansible_search_path will contain ['/base/roles/role_b','/base/roles/role_a','/base/roles/role_b/tasks']. Note the /base/roles/role_a in there. Now, use the variable inclusion idiom:

# 
- name: Set version-specific variables for role_b
  include_vars: "{{ item }}"
  with_first_found:
    - files:
        - "{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
        - "{{ ansible_distribution }}_{{ ansible_distribution_major_version }}.yml"
        - "{{ ansible_distribution }}.yml"
        - "{{ ansible_os_family }}.yml"
        - "default.yml"
# 

If role_b does not define vars/Fedora_31.yml, but role_a does, role_b will include vars/Fedora_31.yml from role_a, and will not include any vars from role_b.

Solutions

It is best to explicitly specify the path to use, rather than relying on the default behavior:

# 
- name: Set version-specific variables for role_b
  include_vars: "{{ item }}"
  with_first_found:
    - files:
        - "{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
        - "{{ ansible_distribution }}_{{ ansible_distribution_major_version }}.yml"
        - "{{ ansible_distribution }}.yml"
        - "{{ ansible_os_family }}.yml"
        - "default.yml"
      paths:
        - "{{ role_path }}/vars"
# 

The role_path is a built-in which will be /base/rolename, so you are guaranteed to look in /base/role_b/vars for the files, and find only the files for your role.

Alternatives

with_first_found will fail if no files are found. If you don’t want to create files for every possible combination of platform/version, or you don’t want to create vars/default.yml, you can use the skip: true flag:

# 
- name: Set version-specific variables for role
  include_vars: "{{ item }}"
  with_first_found:
    - files:
        - "{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
        - "{{ ansible_distribution }}.yml"
      paths:
        - "{{ role_path }}/vars"
      skip: true
# 

In this example, I rely on vars/main.yml for most everything, and only provide some platform/version customizations.

Another way to do this is the following:

# 
- name: Set version-specific variables for role
  include_vars: "{{ item }}"
  loop:
    - "{{ role_path }}/vars/{{ ansible_os_family }}.yml"
    - "{{ role_path }}/vars/{{ ansible_distribution }}.yml"
    - "{{ role_path }}/vars/{{ ansible_distribution }}_{{ ansible_distribution_major_version }}.yml"
    - "{{ role_path }}/vars/{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
  when: item is file
# 

The files in the loop are in order from least specific to most specific. Each file in the loop list will allow you to add or override additional variables to specialize the values for platform and/or version. Using the when: item is file test means that you do not have to provide all of the vars/ files, only the ones you need. For example, if every platform except Fedora uses srv_name for the service name, you can define __myrole_service: srv_name in vars/main.yml then define __myrole_service: srv2_name in vars/Fedora.yml. In cases where this would lead to duplicate vars files for similiar distibutions (e.g. CentOS 7 and RHEL 7), use symlinks to avoid the duplication.

Tasks

Platform specific tasks, however, are different. You probably want to perform platform specific tasks once, for the most specific match. In that case, use with_first_found with the file list in order of most specific to least specific, including a “default”:

# 
- name: Perform platform/version specific tasks
  include_tasks: "{{ item }}"
  with_first_found:
    - files:
        - "setup_{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
        - "setup_{{ ansible_distribution }}_{{ ansible_distribution_major_version }}.yml"
        - "setup_{{ ansible_distribution }}.yml"
        - "setup_{{ ansible_os_family }}.yml"
        - "setup_default.yml"
      paths:
        - "{{ role_path }}/tasks"
# 

And same with the vars files above, if you don’t want to have to provide the default, or only some files, use skip: true:

# 
- name: Perform platform/version specific tasks
  include_tasks: "{{ item }}"
  with_first_found:
    - files:
        - "setup_{{ ansible_distribution }}_{{ ansible_distribution_version }}.yml"
        - "setup_{{ ansible_distribution }}.yml"
      paths:
        - "{{ role_path }}/tasks"
      skip: true
#