Nested Loops - noobient/noobuntu GitHub Wiki

Rationale

If you develop Galaxy roles, you normally want to let your users use loops in include_role to call your stuff in an efficient way. E.g.:

- include_role:
    name: firewalld
  loop:
    - { service: 'foo', port: '1234/tcp' }
    - { service: 'foo', port: '5678/udp' }

instead of

- include_role:
    name: firewalld
  vars:
    service: 'foo'
    port: '1234/tcp'

- include_role:
    name: firewalld
  vars:
    service: 'foo'
    port: '5678/udp'

The issue

In the Galaxy role, you will refer to these with item. The problem arises when you include_role within an include_role, as you end up with multiple item variables at different levels. The obvious solution to this would appear to be the use of loop_control's loop_var, except for the fact that the included role will don't know anything about what loop_var is. According to the Ansible Loops docs, you may use ansible_loop_var to find that out, which is great, except it's not accessible in the included role.

Why? Because according to core engineering architect tech team lead genius wizard genie gods, letting the called role know what the variable name is "makes no sense".

This wouldn't bother me so much if at the same time, Ansible wouldn't nag me with these utterly pointless

The loop variable 'item' is already in use. You should set the loop_var value in the loop_control option for the task to something else to avoid variable collisions and unexpected behavior.

warnings. I've tried really hard to trigger any kind of "unexpected behavior", but there isn't any. Ansible intelligently, automatically deals with scoping. It just works.

Suppressing them is impossible, because they think that's "already implemented", even though warn: false only applies to the shell and command modules. There might be others, but include_role is definitely not one of them. There's no global Ansible config option for this purpose either. Furthermore, using the same item for nested loops won't cause any harm, they will be scoped properly, always. But it prints the warning anyway.

Overriding item

Another wonderful issue is that set_fact apparently lets you override already defined variables, except if it's the item variable. Ansible silently skips that one. Isn't that just wonderful design? How do I know? Test it for yourself.

test1 role

- include_role:
    name: test2
  loop:
    - { foo: 'yes', bar: 'yes' }

test2 role

- include_role:
    name: test3
  loop:
    - { foo: 'no', bar: 'no' }
  loop_control:
    loop_var: wassup
  vars:
    ansible_loop_var: wassup

test3 role

# ensure we redefine an existing variable later
- set_fact:
    itom: 'blahblah'

- set_fact:
    item: "{{ lookup('vars', ansible_loop_var) }}"
    itom: "{{ lookup('vars', ansible_loop_var) }}"

- debug:
    msg: "item: {{ item }}"

- debug:
    msg: "itom: {{ itom }}"

Output

TASK [test3 : debug] ***************************
ok: [127.0.0.1] => {
    "msg": "item: {'foo': 'yes', 'bar': 'yes'}"
}

TASK [test3 : debug] ***************************
ok: [127.0.0.1] => {
    "msg": "itom: {'foo': 'no', 'bar': 'no'}"
}

Wonderful, isn't it?

On top of that, if you use any other, unrelated loop in these roles, it will collide with those as well, because remember, ansible_loop_var is an internal variable, so you'd have to use something like foo_loop_var instead of ansible_loop_var.

Attempting to fix

So to eliminate these warnings, we may try to reimplement ansible_loop_var on our own, as seen above, e.g. in the caller role:

loop_control:
  loop_var: item2
vars:
  foo_loop_var: item2

And in the included role:

set_fact:
  item_priv: "{{ lookup('vars', foo_loop_var|default('item')) }}"

This would have to be called in the included role before you refer to item_priv, and every time control is returned from another nested include, since we redefine item_priv in the nested included role as well.

So we can conclude that this is not an elegant solution.

Actual fix

Role includes

It appears that it's best to just stick with vars in include_role, and use loops at the task level instead of inside the include_role statement.

So if you need to call your role with just one element:

- include_role:
    name: firewalld
  vars:
    service: 'foo'
    port: '1234/tcp'

If you need multiple:

- include_role:
    name: firewalld
  vars:
    service: "{{ item.service }}"
    port: "{{ item.port }}"
  loop:
    - { service: 'foo', port: '1234/tcp' }
    - { service: 'foo', port: '5678/udp' }

This has various benefits:

  • You can forget about prefixing all role variables with item. inside the included role.
  • You can feed your role in a flexible way, e.g. if one parameter is static, while the other is dynamic, you can just:
- include_role:
    name: firewalld
  vars:
    zone: 'cloudflare'
    source: "{{ item }}"
  loop: "{{ cloudflare_ipv6.content.split() }}"
  • You can include your roles both with single and multiple variables, and it will only produce warnings if you use loops in both levels of the nested includes.

Other loops

For loops within the included roles that are unrelated to the includer role's variables must override loop_var to avoid collision. E.g.

- name: "Perform WordPress upgrade on {{ path }}"
  command:
    cmd: "wp --path={{ path }} {{ wp_cmd }}"
  loop:
    - core update
    - core update-db
  loop_control:
    loop_var: wp_cmd

In this case, path comes from the includer role, while wp_cmd is contained inside the included role. If we didn't do this, Ansible would look for path inside the current loop, which obviously either won't have such child, or if it had, it wouldn't be the path we're looking for:

The task includes an option with an undefined variable. The error was: {{ item.path }}: 'ansible.parsing.yaml.objects.AnsibleUnicode object' has no attribute 'path'