06 Aug 2018

Looping in Ansible Evaluates Things

I recently came across a bit of an unusual situation in Ansible that I haven’t had much luck googling, so here’s something to help the next person.

I was trying to loop through a series of dictionaries, running a task with each dictionary defining the majority of the variables that the task would use. I had some global variables, some that varied from dictionary to dictionary, and some that were defined inside the task. I wanted to reference the variables in the task scope in my dictionaries - so, in my dictionaries I had a template for a deploy directory. It would be used multiple times with different values in the variables it references.

Here’s the short version of my findings: Ansible evaluates anything you loop over at the setup time of the loop, including everything in a dictionary, rendering any template string inside.

Here’s some more detail.

So, obviously, this task would fail - exists_in_task_scope hasn’t been defined.


# example.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
  tasks:
    - debug:
        var: exists_in_global_vars
    - debug:
        var: dict_for_looping

# Output
PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [debug] *******************************************************************
ok: [default] => {
    "exists_in_global_vars": "/home/vagrant/loop_test"
}

TASK [debug] *******************************************************************
ok: [default] => {
    "dict_for_looping": "VARIABLE IS NOT DEFINED!"
}

PLAY RECAP *********************************************************************
default                    : ok=3    changed=0    unreachable=0    failed=0

While this works great - when we run debug, we evaluate our template strings the variable exists.


# example.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    exists_in_task_scope: superb
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
  tasks:
    - debug:
        var: exists_in_global_vars
    - debug:
        var: dict_for_looping

# output
PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [debug] *******************************************************************
ok: [default] => {
    "exists_in_global_vars": "/home/vagrant/loop_test"
}

TASK [debug] *******************************************************************
ok: [default] => {
    "dict_for_looping": {
        "first": {
            "a_var": "/home/vagrant/loop_test/superb/after"
        },
        "second": {
            "a_var": "/home/vagrant/loop_test/superb/after"
        }
    }
}

PLAY RECAP *********************************************************************
default                    : ok=3    changed=0    unreachable=0    failed=0

Now, let’s look at looping through this dictionary. If I try to loop through it and display a message, we see that the task is run with an item that’s been evaluated:


# loops.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    exists_in_task_scope: superb
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
  tasks:
    - debug:
        msg: Hello
      with_dict: "{{ dict_for_looping }}"

# output
LAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [include_role : loops] ****************************************************

TASK [loops : debug] ***********************************************************
ok: [default] => (item={'value': {u'a_var': u'/home/vagrant/loop_test/superb/after'}, 'key': u'second'}) => {
    "msg": "Hello"
}
ok: [default] => (item={'value': {u'a_var': u'/home/vagrant/loop_test/superb/after'}, 'key': u'first'}) => {
    "msg": "Hello"
}

PLAY RECAP *********************************************************************
default                    : ok=2    changed=0    unreachable=0    failed=0

And if I strip our task scope variable, the task fails, even though we never explicitly access a_var:


# loops.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
  tasks:
    - debug:
        msg: Hello
      with_dict: "{{ dict_for_looping }}"

# output
PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [debug] *******************************************************************
fatal: [default]: FAILED! => {"msg": "'exists_in_task_scope' is undefined"}
        to retry, use: --limit @/Users/mac/code/ansible-loop-resolution/loops.retry

PLAY RECAP *********************************************************************
default                    : ok=1    changed=0    unreachable=0    failed=1

The same is true of a loop instead of with_dict.

You can’t even loop through the keys of the dictionary:


# loops.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
  tasks:
    - debug:
        msg: Hello
      loop: "{{ dict_for_looping.keys() }}"

# output
PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [debug] *******************************************************************
fatal: [default]: FAILED! => {"msg": "'exists_in_task_scope' is undefined"}
        to retry, use: --limit @/Users/mac/code/ansible-loop-resolution/loops.retry

PLAY RECAP *********************************************************************
default                    : ok=1    changed=0    unreachable=0    failed=1

You can’t even get away with putting the keys into a separate variable, like this:


---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    dict_for_looping:
      first:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
      second:
        a_var: "{{ exists_in_global_vars }}/{{ exists_in_task_scope }}/after"
    dict_keys: "{{ dict_for_looping.keys() }}"
  tasks:
    - debug:
        msg: Hello
      loop: "{{ dict_keys }}"

So, in summary, anything you loop over gets evaluated at loop setup time, and as such you can’t use it for templating things using templates within your looping variables.

Here’s how I turned this around and solved my particular problem: I made a template that I would only call when I had an item in my scope, and referred to that repeatedly. Like so:


# loops.yml
---
- hosts: all
  vars:
    exists_in_global_vars: "/home/vagrant/loop_test"
    template: "{{ exists_in_global_vars }}/{{ item.value.another_var }}/after"
    dict_for_looping:
      first:
        another_var: first
      second:
        another_var: second
  tasks:
    - debug:
        msg: "Hello {{template}}"
      loop: "{{ dict_for_looping | dict2items }}"

# output
PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [default]

TASK [debug] *******************************************************************
ok: [default] => (item={'key': u'second', 'value': {u'another_var': u'second'}}) => {
    "msg": "Hello /home/vagrant/loop_test/second/after"
}
ok: [default] => (item={'key': u'first', 'value': {u'another_var': u'first'}}) => {
    "msg": "Hello /home/vagrant/loop_test/first/after"
}

PLAY RECAP *********************************************************************
default                    : ok=2    changed=0    unreachable=0    failed=0

Hope that helps.