How to solve ansible challenge from ansible-core maintainer

During his presentation at CfgMgmtCamp @nitzmahone presented challenge:

“Use Jinja against setup to get a list of active NIC names on a Linux host that have no IPv6 addresses. Not impossible, but harder than it should be!”

I would like to demonstrate how to solve this challenge. I believe one can learn few tricks and approaches while solving this challenge.
I will try to share my line of thinking in this post, please forgive me some clumsiness of the solution.

Let’s start with running playbook with setup task:

- name: Solve jinja challenge
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Get facts
      ansible.builtin.setup:
      register: facts
    

As you might know facts is very big dictionary and, from my point of view, it is not practical to use debug action to print facts to console output. I prefer to use ARA Ansible in this scenario. This is higly underestimated tool in ansible ecosystem (again only my personal point of view).

All you need to run ARA locally is podman/docker, see how to do that in 5 (literally) cli commands: https://hub.docker.com/r/recordsansible/ara-api

So we have ARA running as container on localhost, let’s open localhost:8000 and investigate result of playbook execution.

Click on report sign (left most column) in playbook list, there is only one tasks, click report (again left most) for this tasks.

Inside ansible_facts you will find ansible_interfaces list:

    "ansible_interfaces": [
        "anpi0",
        "anpi1",
        "anpi2",
        "ap1",
        "awdl0",
        "bridge0",
        "en0",
        "en1",
        "en2",
        "en3",
        "en4",
        "en5",
        "en9",
        "gif0",
        "llw0",
        "lo0",
        "stf0",
        "utun0",
        "utun1",
        "utun11",
        "utun2",
        "utun3",
        "utun4",
        "utun5",
        "utun6",
        "utun7",
        "utun8",
        "utun9"
    ],

Also for each interface there is a dictionary with detailed information about this interface:
Pay attention how key is structured - it is ‘ansible_’ + . This will be useful later.

    "ansible_gif0": {
        "device": "gif0",
        "flags": [
            "POINTOPOINT",
            "MULTICAST"
        ],
        "ipv4": [],
        "ipv6": [],
        "macaddress": "unknown",
        "mtu": "1280",
        "type": "unknown"
    },
    "ansible_llw0": {
        "device": "llw0",
        "flags": [
            "UP",
            "BROADCAST",
            "SMART",
            "RUNNING",
            "SIMPLEX",
            "MULTICAST"
        ],
        "ipv4": [],
        "ipv6": [
            {
                "address": "<redacted>",
                "prefix": "64",
                "scope": "0x11"
            }
        ],
        "macaddress": "<redacted>",
        "media": "Unknown",
        "media_select": "autoselect",
        "media_type": "none",
        "mtu": "1500",
        "options": [
            "PERFORMNUD",
            "DAD"
        ],
        "status": "inactive",
        "type": "ether"
    },

Here are two examples one with IPV6 addresses configured and another one without. So we have more or less understanding of the shape of the data we are dealing with. Let’s try to solve the challenge.

My approach - loop throught all the interfaces and selecting all devices there ipv6 key is not an empty list.

Here is how to find list of all interfaces:

      network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"

Let’s take the one sample network interface from the list and try to find detailed information about this interface:

       network_interface_example_name: "ansible_{{ facts.ansible_facts.ansible_interfaces | first }}"

As you can see I construct name as <‘ansible_’+interface name from the list>.
In order to check if our sample network interface has ipv6 attribute, just get this attribute from details dictionary

    network_interface_sample_details: "{{ facts.ansible_facts[network_interface_sample_name] }}"
    network_interface_sample_ipv6: "{{ network_interface_sample_details.ipv6 }}"

Let’s combine everything together. Starting with loop through all interfaces. Add one more task to the playbook, this will be enchanced step by step

    - name: Solution
      ansible.builtin.debug:
        var: "{{ item }}"
      loop: "{{ network_interfaces }}"
      vars:
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"

Run playbook and you will see something similar to this:

TASK [Solution] *****************************************************************************************************************************
ok: [localhost] => (item=anpi0) => {
    "item": "anpi0"
}
ok: [localhost] => (item=anpi1) => {
    "item": "anpi1"
}
... (truncated)

Combining all the steps together we will have something like this, first approach to the solution:

    - name: Solution
      ansible.builtin.debug:
        var: item
      loop: "{{ network_interfaces }}"
      vars:
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"
        network_interface: "{{ item }}"
        network_interface_name: "ansible_{{ network_interface }}"
        network_interface_details: "{{ facts.ansible_facts[network_interface_name] }}"
        network_interface_has_ipv6_addresses: "{{ network_interface_details.ipv6 | length > 0 }}"
      when: network_interface_has_ipv6_addresses

I’ve added five lines, so let’s unwrap them one by one starting from bottom.
We want to print only if network interface has IPV6 addresses - so we add this condition

when: network_interface_has_ipv6_addresses.

When network interface has IPV6 addresses - when list of IPV6 addresses has length more than zero - this is second lime from the bottom:
network_interface_has_ipv6_addresses: "{{ network_interface_details.ipv6 | length > 0 }}".

How can network interface details can be extracted - network interface name (with ansible prefix) is required:

network_interface_details: "{{ facts.ansible_facts[network_interface_name] }}"

Interface name is constructed by adding ansible_ prefix:

network_interface_name: "ansible_{{ network_interface }}"

All the interfaces are from ansible_facts:

network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"

Run the playbook and you will see:

TASK [Solution] *****************************************************************************************************************************
skipping: [localhost] => (item=anpi0) 
skipping: [localhost] => (item=anpi1) 
skipping: [localhost] => (item=anpi2) 
skipping: [localhost] => (item=ap1) 
ok: [localhost] => (item=awdl0) => {
    "item": "awdl0"
}

...(truncated)

Some interfaces are skipped and some are printed. Looks like we are one the right track! Just one more step to solve the challenge. One action that will print all the interfaces names with IPV6 as one list is missing.

At this point you might be tempted to use set_fact to create a list of interfaces with ipv6 addresses, but do not yield to this temptation. There is no need to use set_facts imperative directive in ansible (functional and declarative). There are two use cases to use set_fact - to evaluate variable once (for instance for random variable) or “return” value from ansible role. This is not one of those use cases.

For those who prefer to use set_fact:

    - name: Solution (set_fact)
      ansible.builtin.set_fact:
        network_interfaces_with_ipv6_addresses_only_device_ids: "{{ network_interfaces_with_ipv6_addresses_only_device_ids | default([]) + [network_interface] }}"
      loop: "{{ network_interfaces }}"
      vars:
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"
        network_interface: "{{ item }}"
        network_interface_name: "ansible_{{ network_interface }}"
        network_interface_details: "{{ facts.ansible_facts[network_interface_name] }}"
        network_interface_has_ipv6_addresses: "{{ network_interface_details.ipv6 | length > 0 }}"
      when: network_interface_has_ipv6_addresses

    - name: Solution
      ansible.builtin.debug:
        var: network_interfaces_with_ipv6_addresses_only_device_ids

Run the playbook and you see:

TASK [Solution] ********************************************************************************************************************************************
ok: [localhost] => {
    "network_interfaces_with_ipv6_addresses_only_device_ids": [
        "awdl0",
        "en0",
        "llw0",
        "lo0",
        "utun0",
        "utun1",
        "utun11",
        "utun2",
        "utun3",
        "utun6",
        "utun7",
        "utun8",
        "utun9"
    ]
}

This is already a solution to the challenge. Or maybe not? What do you think? Let’s not stop here, there should be solution without set_fact.

In initial challenge tasks was to only find network interfaces without any IPV6 configured. In real life there might be other condition we are interested in. So let’s make this solution extendable to arbitrary selection condition.

    - name: Solution for arbitrary selection condition (set_fact)
      ansible.builtin.set_fact:
        network_interfaces_with_selection_condition_only_device_ids: "{{ network_interfaces_with_selection_condition_only_device_ids | default([]) + [network_interface] }}"
      loop: "{{ network_interfaces }}"
      vars:
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"
        network_interface: "{{ item }}"
        network_interface_name: "ansible_{{ network_interface }}"
        network_interface_details: "{{ facts.ansible_facts[network_interface_name] }}"
        network_interface_selection_condition: "{{ network_interface_details.media_type is defined and network_interface_details.media_type == 'full-duplex' }}"
      when: network_interface_selection_condition

    - name: Solution
      ansible.builtin.debug:
        var: network_interfaces_with_selection_condition_only_device_ids

There are few changes to previous solution. First, I accumulate results in different variable:

    network_interfaces_with_selection_condition_only_device_ids: "{{ network_interfaces_with_selection_condition_only_device_ids | default([]) + [network_interface] }}"

Second, there is line with selection condition calculation added:

    network_interface_selection_condition: "{{ network_interface_details.media_type is defined and network_interface_details.media_type == 'full-duplex' }}"

Third - select only when selection condition is true:

  when: network_interface_selection_condition

Original challenge statement included word “jinja”, so let’s solve this with jinja. The loop was used in set_fact solution, so jinja loop will be required.

    - name: Solution (jinja)
      ansible.builtin.debug:
        var: network_interfaces_with_ipv6_addresses_only_device_ids
      vars:
        network_interfaces_with_ipv6_addresses_only_device_ids_json: >-
          {%- set comma = joiner(',') -%}
          [{%- for network_interface in network_interfaces -%}
          {%- set network_interface_name = 'ansible_' ~ network_interface -%}
          {%- set network_interface_details = facts.ansible_facts[network_interface_name] -%}
          {%- set network_interface_has_ipv6_addresses = (network_interface_details.ipv6 | length > 0) -%}
          {%- if network_interface_has_ipv6_addresses -%}
          {{ comma() }}"{{ network_interface }}"
          {%- endif -%}
          {%- endfor -%}]
        network_interfaces_with_ipv6_addresses_only_device_ids: "{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}"
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"

This line:

{%- set network_interface_has_ipv6_addresses = (network_interface_details.ipv6 | length > 0) -%}

also can be extended easily with arbitraty condition on network_interface_details.

This solution I do not like at all. It was very hard to write (I am not a jinja expert), especially get all the ‘-’ right (i had to use AI for that), otherwise result is full of unnecessary spaces. And complete solution falls apart with small changes. I spend a lot of time understanding that you have to put something that will be rendered to text inside if statement, otherwise it does not work and fails with obscure error. For me it was another reminder that jinja is a text engine - not a way to process data. And that first solution (set_fact) is processing data I like, but I do not like to use set_fact.

Result of this jinja execution is the same as in set_fact solution above. Some readers might already have guessed what might be the problem with this solution. It is in ‘from_json’ filter.
In ansible-core 2.19 and above if string looks like a json it is not converted to anything, it will stay string, that is why this filter is required. But in older ansible versions conversion in automatic and when filter is applied it will already be list, and you cannot apply ‘from_json’ filter to list, only to string. Here are executions with different ansible-core versions

This is exact playbook that is executed:
---
- name: Solve challenge extended by ansible-core maintainer
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Get facts
      ansible.builtin.setup:
      register: facts

    - name: Solution (jinja)
      ansible.builtin.debug:
        var: network_interfaces_with_ipv6_addresses_only_device_ids
      vars:
        network_interfaces_with_ipv6_addresses_only_device_ids_json: >-
          {%- set comma = joiner(',') -%}
          [{%- for network_interface in network_interfaces -%}
          {%- set network_interface_name = 'ansible_' ~ network_interface -%}
          {%- set network_interface_details = facts.ansible_facts[network_interface_name] -%}
          {%- set network_interface_has_ipv6_addresses = (network_interface_details.ipv6 | length > 0) -%}
          {%- if network_interface_has_ipv6_addresses -%}
          {{ comma() }}"{{ network_interface }}"
          {%- endif -%}
          {%- endfor -%}]
        network_interfaces_with_ipv6_addresses_only_device_ids: "{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}"
        network_interfaces: "{{ facts.ansible_facts.ansible_interfaces }}"

network_interfaces: “{{ facts.ansible_facts.ansible_interfaces }}”



Here are executions:

uv run --with ansible-core==2.20.2 ansible-playbook jinja.yml

bash-5.2$ uv run --with ansible-core==2.20.2 ansible-playbook jinja.yml
Installed 9 packages in 52ms
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match ‘all’
[WARNING]: Deprecation warnings can be disabled by setting deprecation_warnings=False in ansible.cfg.
[DEPRECATION WARNING]: The ‘ansible.posix.profile_tasks’ callback plugin implements the following deprecated method(s): playbook_on_stats. This feature will be removed from the callback plugin API in ansible-core version 2.23. Implement the v2_* equiv
alent callback method(s) instead.

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:10:49 +0100 (0:00:00.070) 0:00:00.070 *******
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:10:49 +0100 (0:00:00.605) 0:00:00.675 *******
ok: [localhost] => {
“network_interfaces_with_ipv6_addresses_only_device_ids”: [
“awdl0”,
“en0”,
“en6”,
“en8”,
“llw0”,
“lo0”,
“utun0”,
“utun1”,
“utun10”,
“utun11”,
“utun12”,
“utun13”,
“utun2”,
“utun3”,
“utun6”,
“utun7”,
“utun8”,
“utun9”
]
}

PLAY RECAP **************************************************************************************************************************************************************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

uv run --with ansible-core==2.19.6 ansible-playbook jinja.yml

bash-5.2$ uv run --with ansible-core==2.19.6 ansible-playbook jinja.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match ‘all’
[WARNING]: Deprecation warnings can be disabled by setting deprecation_warnings=False in ansible.cfg.
[DEPRECATION WARNING]: The ‘ansible.posix.profile_tasks’ callback plugin implements the following deprecated method(s): playbook_on_stats. This feature will be removed from the callback plugin API in ansible-core version 2.23. Implement the v2_* equiv
alent callback method(s) instead.

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:11:37 +0100 (0:00:00.024) 0:00:00.024 *******
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:11:37 +0100 (0:00:00.531) 0:00:00.556 *******
ok: [localhost] => {
“network_interfaces_with_ipv6_addresses_only_device_ids”: [
“awdl0”,
“en0”,
“en6”,
“en8”,
“llw0”,
“lo0”,
“utun0”,
“utun1”,
“utun10”,
“utun11”,
“utun12”,
“utun13”,
“utun2”,
“utun3”,
“utun6”,
“utun7”,
“utun8”,
“utun9”
]
}

PLAY RECAP **************************************************************************************************************************************************************************************************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

uv run --with ansible-core==2.18.13 ansible-playbook jinja.yml

bash-5.2$ uv run --with ansible-core==2.18.13 ansible-playbook jinja.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match ‘all’

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:12:23 +0100 (0:00:00.005) 0:00:00.005 *******
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:12:24 +0100 (0:00:00.597) 0:00:00.602 *******
fatal: [localhost]: FAILED! => {“msg”: “An unhandled exception occurred while templating ‘{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}’. Error was a <class ‘ansible.errors.AnsibleError’>, original message: Unexpected tem
plating type error occurred on ({{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}): the JSON object must be str, bytes or bytearray, not list. the JSON object must be str, bytes or bytearray, not list”}

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

bash-5.2$ uv run --with ansible-core==2.17.14 ansible-playbook jinja.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match ‘all’

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:12:49 +0100 (0:00:00.004) 0:00:00.004 *******
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:12:50 +0100 (0:00:01.481) 0:00:01.485 *******
fatal: [localhost]: FAILED! => {“msg”: “An unhandled exception occurred while templating ‘{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}’. Error was a <class ‘ansible.errors.AnsibleError’>, original message: Unexpected tem
plating type error occurred on ({{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}): the JSON object must be str, bytes or bytearray, not list. the JSON object must be str, bytes or bytearray, not list”}

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

uv run --with ansible-core==2.17.14 ansible-playbook jinja.yml
bash-5.2$ uv run --with ansible-core==2.17.14 ansible-playbook jinja.yml 
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026  14:12:49 +0100 (0:00:00.004)       0:00:00.004 ******* 
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026  14:12:50 +0100 (0:00:01.481)       0:00:01.485 ******* 
fatal: [localhost]: FAILED! => {"msg": "An unhandled exception occurred while templating '{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}'. Error was a <class 'ansible.errors.AnsibleError'>, original message: Unexpected tem
plating type error occurred on ({{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}): the JSON object must be str, bytes or bytearray, not list. the JSON object must be str, bytes or bytearray, not list"}

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



uv run --with ansible-core==2.16.16 ansible-playbook jinja.yml

bash-5.2$ uv run --with ansible-core==2.16.16 ansible-playbook jinja.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match ‘all’

PLAY [Solve challenge extended by ansible-core maintainer] **************************************************************************************************************************************************************************************************

TASK [Get facts] ********************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:13:20 +0100 (0:00:00.006) 0:00:00.006 *******
ok: [localhost]

TASK [Solution (jinja)] *************************************************************************************************************************************************************************************************************************************
Friday 06 February 2026 14:13:21 +0100 (0:00:01.487) 0:00:01.493 *******
fatal: [localhost]: FAILED! => {“msg”: “An unhandled exception occurred while templating ‘{{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}’. Error was a <class ‘ansible.errors.AnsibleError’>, original message: Unexpected tem
plating type error occurred on ({{ network_interfaces_with_ipv6_addresses_only_device_ids_json | from_json }}): the JSON object must be str, bytes or bytearray, not list. the JSON object must be str, bytes or bytearray, not list”}

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

I would like to stop at this point. This is quite a handful information to process (and to write). There is more to come - I’ve already discovered bug in the code above. Also I do not like either of solutions presented. Initially my thought was to present third more functional solution using only filters, no loops at all. Implementing this more functional solution helped me discover bug in my code.

What say you @nitzmahone ? Is this what you had in mind during your presentation when you extended this challenge?

As always any feedback is welcome both negative and positive.

Did I solve the challenge?
Was it “but harder than it should be!” as challenge statement claimed?
Did you find the bug?
Does this code work on you machine?

Here are my answers:
yes, but it can be improved
no, it was not very hard to do, but it was hard to do in jinja
eventually I found it with help of functional approach
Yes, it works on my laptop, I did not try to run it on a server.

Questions I do not have answer to (yet):
How to make jinja to work with any ansible version?
What solution / approach is the best to solve this kind of challenges?

PS all code is GPL3.0. If someone wants to execute it, there is no warranty or liabiilty.

This program is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without

even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

You should have received a copy of the GNU General Public License along with this program.
If not, see https://www.gnu.org/licenses/.

2 Likes