Search code examples
ansiblejinja2

Mapping an attribute of a nested structure


I have a nested structure of network interfaces.

interfaces:
  - name: bond0
    bonding_xmit_hash_policy: layer3+4
    bridge:
      name: br0
      inet: 12.34.56.78/24
    slaves:
      - name: eth0
        mac: xx:xx:xx:xx:xx:xx
      - name: eth1
        mac: xx:xx:xx:xx:xx:xx
      - name: eth2
        mac: xx:xx:xx:xx:xx:xx
      - name: eth3
        mac: xx:xx:xx:xx:xx:xx

I now want to generate a flat list of all defined interface names:

  • bond0
  • br0
  • eth0
  • eth1
  • eth2
  • eth3

In theory this should be simple but I fail to extract the names of of the slave interfaces (eth0 - eth3)

Here are the working parts:

  1. List of interfaces on the root level (bond0 in this example)

    interfaces | map(attribute='name') | list
  2. List of bridge interfaces

    interfaces | selectattr('bridge', 'mapping') | map(attribute='bridge') | map(attribute='name') | list

Here is my attempt to get all the slave interface names:

interfaces | selectattr('slaves') | map(attribute='slaves') | map(attribute='name') | list

In words, first reduce the list of interfaces and only get those interfaces which have a slaves attribute. Then with the map filter get the slaves. Until here it works and if I output the result it looks like this:

[
    {
        "mac": "xx:xx:xx:xx:xx:xx", 
        "name": "eth0"
    }, 
    {
        "mac": "xx:xx:xx:xx:xx:xx", 
        "name": "eth1"
    }, 
    {
        "mac": "xx:xx:xx:xx:xx:xx", 
        "name": "eth2"
    }, 
    {
        "mac": "xx:xx:xx:xx:xx:xx", 
        "name": "eth3"
    }
]

This clearly is a list of objects. And this is actually the same format as the interfaces on the root level of the structure (bond0). But when I try to again get the name attribute of all objects with map it will fail, the result is [Undefined]. (Note the []. It seems to be a list of Undefined, not simply undefined)

But this is exactly what the map filter should do:

Applies a filter on a sequence of objects or looks up an attribute. This is useful when dealing with lists of objects but you are really only interested in a certain value of it.

The basic usage is mapping on an attribute. Imagine you have a list of users but you are only interested in a list of usernames

For testing purpose I also tried to see what happens when I use selectattr:

interfaces | selectattr('slaves') | map(attribute='slaves') | selectattr('name') | list

This should make no difference since all objects do have a name property, but Ansible is failing with:

FAILED! => {"failed": true, "msg": "ERROR! 'list object' has no attribute 'name'"}

Somehow it appears there is a list inside the list, which is not shown by the debug task since the output appears to be a list of objects but from point of view of Jinja it appears it is working with a list of lists?

Has anyone an idea what is going on and how to simply get the interface names out of that list?

For now I solved this with a custom filter plugin but I don't understand why this does not work right out of the box. If this stays unsolved and anyone comes across the same issue, here is my plugin:

class FilterModule(object):
    
    def filters(self):
        return {
            'interfaces_flattened': self.interfaces_flattened
        }
    def interfaces_flattened(*args):
        names = []
        for interface in args[1]:
            names.extend(get_names(interface))
        return names

def get_names(interface):
    names = []
    if "name" in interface:
        names.append(interface["name"])
    if "bridge" in interface:
        names.append(interface["bridge"]["name"])
    if "slaves" in interface:
        for slave in interface["slaves"]:
            names.extend(get_names(slave))
    return names

Solution

  • Q: "Flat list of all defined interface names."

    A: There are more options.

    • Use the filter json_query. For example,
      names: "{{ interfaces|
                 json_query('[].[name, *.name, *[].name]')|
                 flatten }}"
    

    gives

      names: [bond0, br0, eth0, eth1, eth2, eth3]
    
    • The iteration
        - set_fact:
            names: "{{ names|d([]) + [item.split(':')|last|trim] }}"
          loop: "{{ (interfaces|to_nice_yaml).splitlines() }}"
          when: item is match('^.*\\s+name:\\s+.*$')
    

    gives

      names: [br0, bond0, eth0, eth1, eth2, eth3]
    

    Example of a complete playbook for testing

    - hosts: localhost
    
      vars:
    
        interfaces:
          - name: bond0
            bonding_xmit_hash_policy: layer3+4
            bridge:
              name: br0
              inet: 12.34.56.78/24
            slaves:
              - name: eth0
                mac: xx:xx:xx:xx:xx:xx
              - name: eth1
                mac: xx:xx:xx:xx:xx:xx
              - name: eth2
                mac: xx:xx:xx:xx:xx:xx
              - name: eth3
                mac: xx:xx:xx:xx:xx:xx
    
        names: "{{ interfaces|
                   json_query('[].[name, *[].name]')|
                   flatten }}"
    
      tasks:
    
        - debug:
            var: names|to_yaml
    
        - set_fact:
            names: "{{ names + [item.split(':')|last|trim] }}"
          loop: "{{ (interfaces|to_nice_yaml).splitlines() }}"
          when: item is match('^.*\\s+name:\\s+.*$')
          vars:
            names: []
        - debug:
            var: names|to_yaml