Variables from not-activated roles get set

I want to have an Ansible-Jinja2 expression with which I can detect whether a specific variable with a dynamic name is set in host/group vars or a role that is included. (Concretely, something like repo_backports_$distroname: true to enable the backports repo centrally. Anything that needs backports, playbook task or role, just sets it; even one suffices for presence; none setting it will cause removal.)

I’m unable to find a way to query such a variable dynamically and while debugging found that even statically does not work:

Assume two hosts, frw001 and frw254. They belong to the same group. frw001 has migrated_to_kea_dhcpd set to true in hostvars, frw254 not.

I have:

$ git grep testvar
roles/common-pcengine/defaults/main.yml:testvar: false
roles/dhcpd4/defaults/main.yml:testvar: true

I’ve experimented already with a playbook that only includes the frw254 host:

- hosts:
  - frw254
  roles:
    - role: common-pcengine
    - role: dhcpd   
      when: not (migrated_to_kea_dhcpd | default(false))
    - role: dhcpd4
      when: (migrated_to_kea_dhcpd | default(false))

  tasks:
  - name: statically
    debug:
      msg: v={{ testvar | default(false) }},m={{ migrated_to_kea_dhcpd | default(false) }}
    tags: x

(Yes, this is not an MWE.)

Yet, when I start it, testvar shows up as true:

$ ansible-playbook playbooks/groups/pcengine.yml -l 
frw254 -t x
[…]
TASK [statically] ***********************************************************************************************
ok: [frw254] => {
    "msg": "v=True,m=False"
}

I’ve put debug: into every role file and can confirm that frw254 is in role dhcpd, not dhcpd4, so roles/dhcpd4/defaults/main.yml should not be read/used.

But, for some reason, it is?!

Is there a way I can trace when and from where a variable is set?

btw, if I comment that one out, it does show false, so nothing else sets this variable

imported roles ALWAYS set the variables, the question is ‘when’ does it happen, defaults are special in many ways, also being the lowest precedence. A role will take it’s own defaults over other role defaults when in conflict, but the variable will be set if at all defined in the role and will affect subsequent roles and tasks depending on the import order.

But the role dhcpd4 isn’t imported, because the migrated_to_kea_dhcpd variable is not set and thus defaults to false.

That’s actually what I want… but only if the role is actually used!

Here’s an MWE exhibiting the bug:

tg@tgb1:/tmp/mwe $ cat roles/extra/defaults/main.yml
foo: true
tg@tgb1:/tmp/mwe $ cat roles/extra/tasks/main.yml
---
- name: hi from role extra
  debug: msg=hi
tg@tgb1:/tmp/mwe $ cat testplay.yml
- hosts: localhost
  become: false
  gather_facts: false
  roles:
  - role: extra
    when: false
  tasks:
  - name: variable test
    debug:
      msg: variable foo={{ foo | default(false) }}
tg@tgb1:/tmp/mwe $ ansible-playbook testplay.yml                                                                
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
 
PLAY [localhost] ************************************************************************************************
 
TASK [extra : hi from role extra] *******************************************************************************
skipping: [localhost]
 
TASK [variable test] ********************************************************************************************
ok: [localhost] => {
    "msg": "variable foo=True"
}
 
PLAY RECAP ******************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
 

If I comment out the line in roles/extra/defaults/main.yml it correctly shows False.

using - role: "{{ 'extra' if false else omit }}" also does not work

in the more concrete case…

- role: "{{ (migrated_to_kea_dhcpd | default(false)) | ternary('dhcpd4', 'dhcpd') }}"

… also does not work right: the variable is ignored and even the migrated host suddenly uses the old role dhcpd instead, so this is also broken.

imports are NOT conditional, roles works like import_role. The when is applied to task execution, it does not affect importing of variables, you need to use include_role if you want that functionality.

… ah!

That is… annoying, as import_role is imperative and doesn’t work with role dependencies.

roles, import_role and include_role are all ‘imperative’ and all work with dependencies.

As far I understand it is impossible to read variable value at specific precedence level (in this case host/group/role). If variables are read they are always read from higher precedence to lowest. So if on any precendence level value is set (in the scope where read is happening), variable is considered set.

This is because as was mentioned playbook directive role: is same as import_role and import_role default behavior when making role variables public, is opposite to include_role (which does not make any variables public by default)

parameter public is set to true by default. So all role variables are public after role import.

Try setting it to false and use import_role explicitly, instead or role, role variables (defaults, vars) will not be public, so they will not affect anything “outside” the role.

But variables are not always public, they are not always “visible” to subsequent actions.
As mentioned in the docs for public parameter

Just to expand and clarify on this statement.
Role imports with import_role are not conditional in a sense that if directive when is evaluated to false, role is anyway imported. But no actions from the role will be executed, as they will all “inherit” when: false that is why they will not be executed. But other steps of role import, for instance vars and defaults imports, will be executed.

Role imports are conditional in a sense that one can import different roles using logical conditions in jinja expressions in the role name.

yes, you can change which role is imported dynamically (limited to variables available at import time), but you cannot make conditional the action of importing, only of including.

with imports the variables are always imported, but not always published to the play scope, that is what public controls, any roles or tasks included/imported inside said role will still get access to those variables as they are still in the same context.

But I need them public… iff the role gets imported/executed. And then, visible to the common role that runs before, so set_fact is out.

I fear I’ll have to do this entirely differently, which sucks because then, cleanup once no longer needed is going to be annoying.

What do you what to achieve by this? Not from ansible point of view (what variables to set), but from what do you want to do on your hosts?

I have a common role that creates the APT sources.

I have some roles that need extra repos included, such as backports now concretely.

I toucht the roles could set a variable when included, and the common role could then know the variable and, if present, include the extra repo in the APT sources, and otherwise not, making removal automatic.

I see this like this in my head.

Role 1

- name: Are extra repos needed?
...
Here is a task or tasks to understand if extra repos are needed
...

- name: Set variable role1_extra_repos_needed
  set_fact:
     role1_extra_repos_needed: true / false

Playbook

- name: Playbook
  hosts: all
  gather_facts: false
  tasks:
    - include_role: role1 
    - include_role: common
      vars:
        common_extra_repos_needed: "{{ role1_extra_repos_needed }}"

You can even remove role1 from the playbook by making it a dependency of role common.

role common meta/main.yml file

dependencies:
  - role: role1
 

Playbook

- name: Playbook
  hosts: all
  gather_facts: false
  tasks:
    - include_role: common
      vars:
        common_extra_repos_needed: "{{ role1_extra_repos_needed }}"

After that you can remove line to set common_extra_repos_needed if you set its default value in common role

role common default/main.yml file

common_extra_repos_needed: "{{ role1_extra_repos_needed | default(omit) }}"

Here if variable role1_extra_repos_needed is not defined variable common_extra_repos_needed also will not be defined, and not just set to empty string. That is why default(omit) is required.

Playbook

- name: Playbook
  hosts: all
  gather_facts: false
  tasks:
    - include_role: common

Yes, except the common role runs first, for all hosts (not just those that include the repo)…

I’ll have to solve this differently.

Basically: the common role must add more to the sources.list.d/all.sources file if the host later runs the dhcpd4 role.

I’ll have to solve this differently.

Or I’ll just have to live with the bug. For the current specific case, I only need trixie-backports, packages from which are not installed by default anyway, so if it gets enabled on more systems than strictly needed, it may be not a problem.

Still, Ansible is so close to getting things right, and then that. The when: in a role dependency needs to skip the defaults/main.yml, not just the tasks.