How to loop over "include_role" with a list of variable dictionaries?

Hi all, I am trying to loop over my “create_user” role with a list of “users”:

    - name: Setup users
        include_role:
          name: create_user
      vars: "{{ user_vars }}"
      loop: "{{ users_to_create }}"
      loop_control:
        loop_var: "{{ user_vars }}"

Unfortunately, this does not work, because one cannot dynamically specify the vars dictionary:
ERROR! Vars in a IncludeRole must be specified as a dictionary.

Another solution I tried, was combining block and set_fact:

    - name: Setup users
      block:
        - name: Build vars for user
          set_fact: "{{ user_vars }}"
        - name: Setup user
          include_role:
            name: create_user
      loop: "{{ users_to_create }}"
      loop_control:
        loop_var: "{{ user_vars }}"

Now, this does not work because block does not support loop.

Third try, let’s explicitly specify the vars:

    - name: Setup steward users
      include_role:
        name: create_user
      vars:
        create_user__user_name: "{{ item.create_user__user_name | default(omit)}}"
        create_user__user_group: "{{ item.create_user__user_group | default(omit)}}"
        create_user__user_uid: "{{ item.create_user__user_uid | default(omit)}}"
        create_user__user_home: "{{ item.create_user__user_home | default(omit)}}"
        create_user__ssh_authorized_keys: "{{ item.create_user__ssh_authorized_keys | default(omit)}}"
        create_user__ssh_key: "{{ item.create_user__ssh_key | default(omit)}}"
      loop: "{{ users_to_create }}"

This failed horribly, because now instead of getting the user_home specified in the role’s defaults, I get a lot of __omit_place_holder__ values.

So, what is the Ansible way of looping over include_role with a list of variable dicts?

Deceptively tricky! One note: your using loop_var incorrectly. loop_var sets the variable that the loop data is stored in, if the default item is not desired.

The block solution does not work, but the solution for a loop over a block is to do an extra layer of import/include. For example

---
#main.yml
- name: Include user tasks
  include_tasks: users.yml
  loop: "{{ users_to_create }}"
  loop_control:
    loop_var: user_to_create

---
#users.yml
- name: Build vars for user
  set_fact: { "{{ __user_var.key }}": "{{ __user_var.value }}" }
  loop: "{{ user_to_create | dict2items }}"
  loop_control:
    loop_var: __user_var

- name: Setup user
  include_role:
    name: create_user

Note that ansible does not do variable scopes, so if the first user defines a variable (like create_user__user_group) and the second user doesnt, you will probably run into trouble.

I would suggest handling this in the role, since that seems easier. You could allow the role to accept a list of users, or have the role accept empty strings as inputs and then ignore them. That way your third attempt would work:

    - name: Setup steward users
      include_role:
        name: create_user
      vars:
        create_user__user_name: "{{ item.create_user__user_name | default('')}}"
        create_user__user_group: "{{ item.create_user__user_group | default('')}}"
        create_user__user_uid: "{{ item.create_user__user_uid | default('')}}"
        create_user__user_home: "{{ item.create_user__user_home | default('')}}"
        create_user__ssh_authorized_keys: "{{ item.create_user__ssh_authorized_keys | default('')}}"
        create_user__ssh_key: "{{ item.create_user__ssh_key | default('')}}"
      loop: "{{ users_to_create }}"

Thanks a lot for your detailed explanation, @mikemorency !

Note that ansible does not do variable scopes, so if the first user defines a variable (like create_user__user_group) and the second user doesnt, you will probably run into trouble.

Oh oops, never thought of that. Scary :sweat_smile:

I would suggest handling this in the role, since that seems easier. You could allow the role to accept a list of users, or have the role accept empty strings as inputs and then ignore them.

Makes sense. Is there maybe a good way to replace "null" values with the defaults from <role>/defaults/main.yml?

I dont really have an elegant way. I think you would probably want to introduce another
layer of variables that are used in the role only.

One solution:
in the role vars/main.yml put:

create_user__defaults_user_group: foo

in defaults/main.yml you put (Im not sure this will work. if it doesnt you just need to put foo in two places)

create_user__user_group: "{{ create_user__defaults_user_group }}"

and then in the role tasks:

- name: reassign variable
  set_fact:
    create_user__final_user_group: "{{ create_user__user_group if create_user__user_group != '' else create_user__defaults_user_group }}"

- name: do something
  debug:
    msg: user group is {{ create_user__final_user_group }}

You were really close, but a couple of things are tripping you up. (After the indentation, fixed above.)

The first is just syntax. “loop_var” expects a string that is the name it should use for your loop variable instead of item. You’re giving it a variable, which may be a string, but looking at the rest of your post I think you mean “user_vars” literally, like so:

loop_control:
  loop_var: user_vars

The second thing is a higher-level problem. It looks like you want vars: to create several discrete variables based on the content of each item in {{ users_to_create }}. You can do that - as you found with your “Third try”, but all the __omit_place_holder__ values scared you off.

And this is why I called this a higher-level problem. If you absolutely want to go the route of setting a bunch of discrete variables (hint: you don’t), then your “Third try” is the way to go, omits and all. Unfortunately your role then has to be (re)written to deal with the omit place holders and undefined variables. Basically, you add a lot of code to your include_role task, and then have to add a lot more code to the tasks in your role to deal with the mess your discrete variables made.

It’s far better in my opinion to pass in the user_vars (or item if you don’t use loop_control.loop_var) as a single dict and let the role pick out the pieces it needs. You never showed us what your users_to_create list looks like, so I made some assumptions and put together the example below. It doesn’t actually create users - the included role just contains a debug task - but it builds the values it can and omits the ones it can’t. If your role has defaults/main.yml then you could pull in those when appropriate. The point is, it makes it much easier to look over a list of dicts and keeps you out of the lots-of-discrete-variables pattern.

Opinions vary, but here’s the way I would do it.

---
# Playbook Hoeze_01.yml
- name: Loop over include_role
  hosts: localhost
  gather_facts: false
  vars:
    users_to_create:
      - user_name: "user1"
        user_group: "group1"
        user_uid: "100001"
        user_home: "/home/user1"
        ssh_authorized_keys: "_authorized_key_1_"
        ssh_key: "_ssh_key_1_"

      - user_name: "user2"
        user_uid: "100002"
        ssh_authorized_keys: "_authorized_key_2_"

  tasks:
    - name: Setup users
      include_role:
        name: hoeze_01_create_user
      loop: "{{ users_to_create }}"
      loop_control:
        loop_var: user_vars

and the included role:

---
# roles/hoeze_01_create_user/tasks/main.yml
- name: Show the vars this role was given
  ansible.builtin.debug:
    msg: |
      user_name:  "{{ user_vars.user_name }}"
      user_group: "{{ user_vars.user_group | default(user_vars.user_name) }}"
      user_uid:   "{{ user_vars.user_uid | default(omit) }}"
      user_home:  "{{ user_vars.user_home | default('/home/' ~ user_vars.user_name) }}"
      ssh_authorized_keys: "{{ user_vars.ssh_authorized_keys | default(omit) }}"
      ssh_key:    "{{ user_vars.ssh_key | default(omit) }}"

And here’s the whole playbook run log:

utoddl@tango:~/ansible$ ansible-playbook Hoeze_01.yml -i localhost,

PLAY [Loop over include_role] **************************************************

TASK [Setup users] *************************************************************
included: hoeze_01_create_user for localhost => (item={'user_name': 'user1', 'user_group': 'group1', 'user_uid': '100001', 'user_home': '/home/user1', 'ssh_authorized_keys': '_authorized_key_1_', 'ssh_key': '_ssh_key_1_'})
included: hoeze_01_create_user for localhost => (item={'user_name': 'user2', 'user_uid': '100002', 'ssh_authorized_keys': '_authorized_key_2_'})

TASK [hoeze_01_create_user : Show the vars this role was given] ****************
ok: [localhost] => 
  msg: |-
    user_name:  "user1"
    user_group: "group1"
    user_uid:   "100001"
    user_home:  "/home/user1"
    ssh_authorized_keys: "_authorized_key_1_"
    ssh_key:    "_ssh_key_1_"

TASK [hoeze_01_create_user : Show the vars this role was given] ****************
ok: [localhost] => 
  msg: |-
    user_name:  "user2"
    user_group: "user2"
    user_uid:   "100002"
    user_home:  "/home/user2"
    ssh_authorized_keys: "_authorized_key_2_"
    ssh_key:    "__omit_place_holder__5cbb9d5f92391d488267e45624dd72729776c145"

PLAY RECAP *********************************************************************
localhost                  : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
1 Like

Congratulations! You managed to touch two of my pet peeves in one sentence. :slight_smile:

The first is “singleton” vs. “list”. In principle, you’d like the user to be able to provide either a single thing or a list of those things - whichever is more natural. Think of the way we can specify tags on a task. It can be single:

tags: alpha

or a list:

tags:
  - alpha
  - beta

So in principle it would be nice if {{ users_to_create }} could be a single dict or a list of dicts. You can either make that work by looping over the include_role task, or the included role’s tasks/main.yml could loop over its own included_tasks. Wherever we make that happen, it’s just a matter of saying

loop: "{{ [users_to_create] | flatten }}"

This does nothing to users_to_create if it’s already a list, but if it’s a single dict, now it’s a list with that dict as its single item. As long as whatever is being called can handle a list, then you don’t need one case for a list and another for a single dict. You get the single case for free if your code can handle lists.


The other pet peeve you touched on is defining otherwise undefined variables as empty strings. There are cases where either undef or length-zero strings are bad™, but in general I prefer to leave things undefined as long as possible. Those things often end up in parameters to modules where `|default(omit)` really shines. There's no way to undefine a variable in Ansible, so I avoid burning that bridge when I can. If I can't avoid it, I'll set a variable to `omit` rather than `""`. Then if it ends up as a module parameter, it has the intended effect of omitting that parameter anyway, and doesn't intrude on the case where `""` is a legitimate value.