Role variables not properly scoped: subsequent roles inherit variables from previous roles instead of loading their own

According to Ansible’s variable precedence rules, variables defined in a role’s
vars/main.yml should be scoped to that role. However, when multiple roles
define the same variable name, a later role appears to inherit the variable
value from the earlier role (not the problem) instead of loading its own
role-scoped variables (the problem).

E.g.,
role1 has a variable named users in role1/vars/main.yml.

role2 has a variable named users in role2/vars/main.yml.

First, role1 executes, automatically loads role1/vars/main.yml and finds the
role-local users.

Then, role2 executes, inherits users from role1 (which is okay), then
should automatically load role2/vars/main.yml, find the role-local
users, and replace users from role1 with users from role2.

What I described appears to conform to Ansible’s variable precedence as
described at →
https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html#understanding-variable-precedence

For both role executions, 15. role vars (as defined in Role directory structure) should define the precedence, thus the newer users should be used
in role2. Nothing of lower precedence (p<15) is at play, and nothing of
higher precedence (p>15) is at play.

As a practical matter, I understand that I can work around this behavior by
using variable name based scoping, however I’m trying to understand why this is
happening. (PEBCAK, bug, documentation bug, etc.)

Ansible: core 2.18.1
Python: 3.9.6
OS: Darwin 23.6.0

Reproducible example:

# test.yml
---
- name: Test playbook
  hosts: all
  become: true
  roles:
    - users_os_bundled
    - users_admin

# roles/users_os_bundled/tasks/main.yml
---
- name: Load OS distribution specific variables
  ansible.builtin.include_vars:
    file: "vars/distribution/{{ ansible_distribution }}.yml"
  when: ansible_distribution is defined

- name: Remove user and primary group
  include_tasks: roles/user_manage/tasks/remove_user.yml
  loop: "{{ users }}"
  tags: ['users']

# roles/users_os_bundled/vars/distribution/Ubuntu.yml
---
users:
  - name: ubuntu
    remove: true
  - name: test1
  - name: test2

# roles/users_admin/tasks/main.yml
---
- name: Add user and primary group
  include_tasks: roles/user_manage/tasks/install_user.yml
  loop: "{{ users }}"
  tags: ['users']

# roles/users_admin/vars/main.yml
---
users:
  - name: alice
    passwd: '<redacted>'
    uid: 1234
    comment: Alice
    shell: "{{ valid_shells['zsh'] }}"
    ssh_keys:
      - 'ssh-ed25519 <redacted>'

# roles/user_manage/tasks/remove_user.yml
---
- name: Remove user and primary group
  block:
    - name: Remove user "{{ item.name }}"
      ansible.builtin.user:
        name: "{{ item.name }}"
        state: absent
        groups: []
        remove: "{{ item.remove | default(false) }}"
        force: "{{ item.force | default(false) }}"

    - name: Call -> remove_group "{{ item.name }}"
      include_tasks: roles/user_manage/tasks/remove_group.yml

  rescue:
    - name: Report remove group failure
      debug:
        msg: >-
          Failed to remove user and primary group {{ item.name }}.
          This could leave orphaned resources.
          Error: {{ ansible_failed_result | default('unknown') }}

# roles/user_manage/tasks/remove_group.yml
---
- name: Remove group
  block:
    - name: Remove group '{{ item.name }}'
      ansible.builtin.group:
        name: "{{ item.name }}"
        state: absent

  when:
    - item.name is defined

  rescue:
    - name: Report remove group failure
      debug:
        msg: >-
          Failed to remove group {{ item.name }}.
          Error: {{ ansible_failed_result | default('unknown') }}

# roles/user_manage/tasks/install_user.yml
---
- name: Install user
  block:
    - name: Call -> install_group "{{ item.name }}"
      include_tasks: roles/user_manage/tasks/install_group.yml
      vars:
        group_name: "{{ item.name }}"
        group_gid: "{{ item.uid }}"

    - name: Install user "{{ item.name }}"
      ansible.builtin.user:
        name: "{{ item.name }}"
        uid: "{{ item.uid }}"
        group: "{{ item.name }}"
        groups: "{{ item.groups | default(omit) }}"
        comment: "{{ item.comment | default(omit) }}"
        home: "{{ item.home | default(home_dir_prefix + '/' + item.name) }}"
        shell: "{{ item.shell | default(valid_shells.bash) }}"
        password: "{{ item.passwd | default(omit) }}"
        system: "{{ item.system | default(false) }}"
        state: present

    - name: Call -> install_ssh_keys for "{{ item.name }}"
      include_tasks: roles/user_manage/tasks/install_ssh_keys.yml
      when:
        - item.ssh_keys is defined
        - item.ssh_keys | length > 0

  when:
    - item.name is defined
    - item.comment is defined
    - item.home is defined

  rescue:
    - name: Report failure to install user and primary group
      debug:
        msg: >-
          Failed to install user {{ item.name }}.
          Error: {{ ansible_failed_result | default('unknown') }}

# roles/user_manage/tasks/install_group.yml
---
- name: Install group
  block:
    - name: Install group "{{ item.name }}"
      ansible.builtin.group:
        name: "{{ item.name }}"
        gid: "{{ item.gid | default(omit) }}"
        system: "{{ item.system | default(false) }}"
        state: present

  when:
    - item.name is defined

  rescue:
    - name: Report install group failure
      debug:
        msg: >-
          Failed to install group {{ item.name }}.
          Error: {{ ansible_failed_result | default('unknown') }}

# roles/user_manage/tasks/install_ssh_keys.yml
---
- name: Install SSH keys
  block:
    - name: Add SSH authorized keys for "{{ item.name }}"
      ansible.builtin.authorized_key:
        user: "{{ item.name }}"
        key: "{{ item.ssh_keys }}"
        manage_dir: true
        exclusive: true
        state: present

  when:
    - item.name is defined
    - item.ssh_keys is defined
    - item.ssh_keys | length > 0

  rescue:
      - name: Report install SSH authorized keys failure
        debug:
          msg: >-
            Failed to install SSH authorized keys for {{ item.name }}.
            Error: {{ ansible_failed_result | default('unknown') }}

Log:

% ansible-playbook test.yml -i inventory.yml --limit 10.1.2.3 -K --check
...
TASK [users_os_bundled : Load OS distribution specific variables] ************************************************************************************
ok: [10.1.2.3]

TASK [users_os_bundled : Remove user and primary group] **********************************************************************************************
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_user.yml for 10.1.2.3 => (item={'name': 'ubuntu', 'remove': True})
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_user.yml for 10.1.2.3 => (item={'name': 'test1'})
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_user.yml for 10.1.2.3 => (item={'name': 'test2'})

TASK [users_os_bundled : Remove user "ubuntu"] *******************************************************************************************************
ok: [10.1.2.3]

TASK [users_os_bundled : Call -> remove_group "ubuntu"] **********************************************************************************************
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_group.yml for 10.1.2.3

TASK [users_os_bundled : Remove group 'ubuntu'] ******************************************************************************************************
ok: [10.1.2.3]

TASK [users_os_bundled : Remove user "test1"] **********************************************************************************************************
changed: [10.1.2.3]

TASK [users_os_bundled : Call -> remove_group "test1"] *************************************************************************************************
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_group.yml for 10.1.2.3

TASK [users_os_bundled : Remove group 'test1'] *********************************************************************************************************
changed: [10.1.2.3]

TASK [users_os_bundled : Remove user "test2"] ******************************************************************************************************
changed: [10.1.2.3]

TASK [users_os_bundled : Call -> remove_group "test2"] *********************************************************************************************
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/remove_group.yml for 10.1.2.3

TASK [users_os_bundled : Remove group 'test2'] *****************************************************************************************************
changed: [10.1.2.3]

TASK [users_admin : Debug users variable source] *****************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Add user and primary group] ******************************************************************************************************
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/install_user.yml for 10.1.2.3 => (item={'name': 'ubuntu', 'remove': True})
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/install_user.yml for 10.1.2.3 => (item={'name': 'test1'})
included: /Users/imcnish/Development/ansible/roles/user_manage/tasks/install_user.yml for 10.1.2.3 => (item={'name': 'test2'})

TASK [users_admin : Call -> install_group "ubuntu"] **************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Install user "ubuntu"] ***********************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Call -> install_ssh_keys for "ubuntu"] *******************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Call -> install_group "test1"] *****************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Install user "test1"] **************************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Call -> install_ssh_keys for "test1"] **********************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Call -> install_group "test2"] *************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Install user "test2"] **********************************************************************************************************
skipping: [10.1.2.3]

TASK [users_admin : Call -> install_ssh_keys for "test2"] ******************************************************************************************
skipping: [10.1.2.3]
...

Role vars are scoped to the play level, not the role. As such, the last defined role, overwrites the vars of previous roles.

You may want to utilize include_role instead, which defaults to not exposing it’s role vars, effectively keeping them scoped to the role itself.

1 Like

That’s my understanding as well, Role vars are scoped to the play level and last defined role, overwrites the vars of previous roles
…but that is not what’s happening.

If the behavior was matching your statement, then (from my simple example) the role2 variable named users would be overwriting the role1 variable named users, but this is not what’s happening.

Ah, I now see something I missed before. You are mixing role vars, and include_vars. include_vars (18) always win over role vars (15):

https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html#variable-precedence-where-should-i-put-a-variable

I initially assumed both were role vars (roles/main.yml)

2 Likes

Thanks for the help! Somehow in my brain I was not fully processing that I am actually calling include_vars because I am solely doing so to emulate an improved/more sane group_vars functionality. Intent vs. reality. =(