Bookmark this page

Implementing Advanced Loops

Objectives

  • Implement loops using structures other than simple lists using lookup plug-ins and filters.

Comparing Loops and Lookup Plug-ins

Using loops to iterate over tasks can help simplify your Ansible Playbooks. The loop keyword loops over a flat list of items. When used with lookup plug-ins, you can construct more complex data in your lists for your loops.

The loop keyword was introduced in Ansible 2.5. Before that, task iteration was implemented by using keywords that started with with_ and ended with the name of a lookup plug-in. The equivalent to loop in this syntax is with_list, and is designed for iteration over a simple flat list. For simple lists, loop is the best syntax to use.

For example, the following three syntaxes have the same results. The first of these is preferred:

- name: using loop
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ mylist }}"

- name: using with_list
  ansible.builtin.debug:
    msg: "{{ item }}"
  with_list: "{{ mylist }}"

- name: using lookup plugin
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ lookup('ansible.builtin.list', mylist) }}"

You can refactor a with_* iteration task to use the loop keyword by using an appropriate combination of lookup plug-ins and filters to match the functionality.

Using the loop keyword in place of with_* loops has the following benefits:

  • No need to memorize or find a with_* style keyword to suit your iteration scenario. Instead, use plug-ins and filters to adapt a loop keyword task to your use case.

  • Focus on learning the plug-ins and filters that are available in Ansible, which have broader applicability beyond iteration.

  • Provides command-line access to the lookup plug-in documentation, with the ansible-navigator doc --mode stdout -t lookup -l command. This documentation helps you to discover lookup plug-ins and to design and use custom iteration scenarios.

Important

Guidance from upstream Ansible on when to use loop or with_ loops has been evolving since Ansible 2.5. Red Hat recommends that you use the loop keyword instead of the with_* loops, but some use cases exist where the earlier syntax might be better. According to the documentation, the with_* syntax is not deprecated and it should be valid for the foreseeable future. The syntax of loop might continue to evolve in future releases of Ansible.

The upstream guidance for Ansible 6 includes the following tips:

  • The loop keyword requires a list, and does not accept a string. If you are having problems, then remember the difference between lookup and query.

  • Any use of with_* that is discussed in "Migrating from with_X to Loop" can be safely converted. These mostly use filters.

  • If you need to use a lookup plug-in to convert a with_* construct to use loop, then it might be clearer to keep using the with_* syntax.

Example Iteration Scenarios

The following examples show some ways to construct more complex loops using Jinja2 expressions, filters, lookup plug-ins, and the with_* syntax.

Iterating over a List of Lists

The with_items keyword provides a way to iterate over complex lists. For example, consider a hypothetical play with the following task:

- name: Remove build files
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  with_items:
    - "{{ app_a_tmp_files }}"
    - "{{ app_b_tmp_files }}"
    - "{{ app_c_tmp_files }}"

The app_a_tmp_files, app_b_tmp_files, and app_c_tmp_files variables each contain a list of temporary files. The with_items keyword combines these three lists into a single list containing the entries from all three lists. It automatically performs one-level flattening of its list.

To refactor a with_items task to use the loop keyword, use the flatten filter. The flatten filter recursively searches for embedded lists, and creates a single list from discovered values.

The flatten filter accepts a levels argument, which specifies an integer number of levels to search for embedded lists. A levels=1 argument specifies that values are obtained by only descending into one additional list for each item in the initial list. This is the same one-level flattening that with_items does implicitly.

To refactor a with_items task to use the loop keyword, you must also use the flatten(levels=1) filter:

- name: Remove build files
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop: "{{ list_of_lists | flatten(levels=1) }}"
  vars:
    list_of_lists:
      - "{{ app_a_tmp_files }}"
      - "{{ app_b_tmp_files }}"
      - "{{ app_c_tmp_files }}"

Important

Because the loop keyword does not perform this implicit one-level flattening, it is not exactly equivalent to with_items. However, provided that the list passed to the loop is a simple list, both methods behave identically. The distinction only matters if you have nested lists.

Iterating over Nested Lists

Data from variable files, Ansible facts, and external services are often a composition of simpler data structures, such as lists and dictionaries. Consider the following users variable:

users:
  - name: paul
    password: "{{ paul_pass }}"
    authorized:
      - keys/paul_key1.pub
      - keys/paul_key2.pub
    mysql:
      hosts:
        - "%"
        - "127.0.0.1"
        - "::1"
        - "localhost"
   groups:
      - wheel

  - name: john
    password: "{{ john_pass }}"
    authorized:
      - keys/john_key.pub
    mysql:
      password: other-mysql-password
      hosts:
         - "utility"
    groups:
      - wheel
      - devops

The users variable is a list. Each entry in the list is a dictionary with the name, password, authorized, mysql, and groups keys. The name and password keys define simple strings, and the authorized and groups keys define lists. The mysql key references another dictionary that contains MySQL related metadata for each user.

Similar to the flatten filter, the subelements filter creates a single list from a list with nested lists. The filter processes a list of dictionaries, and each dictionary contains a key that refers to a list. To use the subelements filter, you must provide the name of a key on each dictionary that corresponds to a list.

To illustrate, consider again the previous users variable definition. The subelements filter enables iteration through all users and their authorized key files defined in the variable:

- name: Set authorized ssh key
  ansible.posix.authorized_key:
    user: "{{ item[0]['name'] }}"
    key: "{{ lookup('ansible.builtin.file', item[1]) }}"
  loop: "{{ users | subelements('authorized') }}"

The subelements filter creates a new list from the users variable data. Each item in the list is itself a two-element list. The first element contains a reference to each user. The second element contains a reference to a single entry from the authorized list for that user.

Iterating over a Dictionary

You often encounter data that is organized as a set of key-value pairs, commonly referred to in the Ansible community as a dictionary, instead of being organized as a list. For example, consider the following definition of a users variable:

users:
  demo1:
    name: Demo User 1
    mail: demo1@example.com
  demo2:
    name: Demo User 2
    mail: demo2@example.com
...output omitted...
  demo200:
    name: Demo User 200
    mail: demo200@example.com

Before Ansible 2.5, you had to use the with_dict keyword to iterate through the key-value pairs of this dictionary. For each iteration, the item variable has two attributes: key and value. The key attribute contains the value of one of the dictionary keys, and the value attribute contains the data associated with the dictionary key:

- name: Iterate over Users
  ansible.builtin.user:
    name: "{{ item['key'] }}"
    comment: "{{ item['value']['name'] }}"
    state: present
  with_dict: "{{ users }}"

Alternatively, you can use the dict2items filter to transform a dictionary into a list, and this is probably easier to understand. The items in this list are structured the same as items produced by the with_dict keyword:

- name: Iterate over Users
  ansible.builtin.user:
    name: "{{ item['key'] }}"
    comment: "{{ item['value']['name'] }}"
    state: present
  loop: "{{ users | dict2items }}"

Iterating over a File Globbing Pattern

You can construct a loop that iterates over a list of files that match a provided file globbing pattern with the fileglob lookup plug-in.

To illustrate, consider the following play:

- name: Test
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Test fileglob lookup plugin
      ansible.builtin.debug:
        msg: "{{ lookup('ansible.builtin.fileglob', '~/.bash*') }}"

The output from the fileglob lookup plug-in is a string of comma-separated files, indicated by the double quotation marks around the msg variable's data:

PLAY [Test] ******************************************************************

TASK [Test fileglob lookup plugin] *******************************************
ok: [localhost] => {
    "msg": "/home/student/.bash_logout,/home/student/.bash_profile,/home/student/.bashrc,/home/student/.bash_history"
}

PLAY RECAP *******************************************************************
localhost  : ok=1    changed=0    unreachable=0    failed=0    ...

To force a lookup plug-in to return a list of values instead of a string of comma-separated values, use the query keyword instead of the lookup keyword. Consider the following modification of the previous playbook example:

- name: Test
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Test fileglob lookup plugin
      ansible.builtin.debug:
        msg: "{{ query('fileglob', '~/.bash*') }}"

The output of this modified playbook indicates that the msg keyword references a list of files, because the data is enclosed in brackets:

PLAY [Test] ******************************************************************

TASK [Test fileglob lookup plugin] *******************************************
ok: [localhost] => {
    "msg": [
        "/home/student/.bash_logout",
        "/home/student/.bash_profile",
        "/home/student/.bashrc",
        "/home/student/.bash_history"
    ]
}

PLAY RECAP *******************************************************************
localhost  : ok=1    changed=0    unreachable=0    failed=0    ...

To use the data from this lookup plug-in in a loop, ensure that the processed data is returned as a list. Both tasks in the following play iterate over the files matching the ~/.bash* globbing pattern:

- name: Both tasks have the same result
  hosts: localhost
  gather_facts: false
  tasks:

    - name: Iteration Option One
      ansible.builtin.debug:
        msg: "{{ item }}"
      loop: "{{ query('fileglob', '~/.bash*') }}"

    - name: Iteration Option Two
      ansible.builtin.debug:
        msg: "{{ item }}"
      with_fileglob: "~/.bash*"

The with_fileglob task is preferred in this case because it is cleaner.

Retrying a Task

Often you do not want a play to run until a specific condition is met. The until directive implements a special kind of loop to specify that kind of condition.

For example, during a blue-green deployment, you must wait until the status of the blue host is good before continuing the deployment on the green host.

- name: Perform smoke test
  ansible.builtin.uri:
    url: "https://{{ blue }}/status"
    return_content: true
  register: smoke_test
  until: "'STATUS_OK' in smoke_test['content']"
  retries: 12
  delay: 10

The blue web server status page is queried every 10 seconds, and fails after 12 attempts if no queries are successful.

Revision: do374-2.2-82dc0d7