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_varvalue in theloop_controloption 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'