In January of 2023, I wrote an article about using an AI assistant to write Ansible Playbooks. At that time, I was not too impressed with the results. But I kept exploring AI, and I have to admit that now (February 2026) things look much better. The following is an article coached by me, but mostly written by AI and edited by a human.
As sysadmins, we often find ourselves managing a fleet of servers that should be identical; but frequently in reality, they're not. Whether you are preparing for a security patch, planning a major version upgrade, or troubleshooting a mysterious bug that only seems to affect some nodes, the first question is always: "Where and what exactly are we running?"
I needed a quick, reliable way to audit my entire inventory and get a clean report of Red Hat Enterprise Linux (RHEL) versions, logically grouped. I decided to see if AI could help me build a sophisticated reporting playbook. What started as a simple prompt, turned into a deep dive into Jinja2 grouping logic, handling unreachable hosts, and mastering the output formatting for Red Hat Ansible Automation Platform.
The challenge: Grouping the fleet
The goal was simple: connect to a list of RHEL servers, capture their version, and print a summary grouped by version at the end of the play. If you’ve ever tried this, you know the debug module’s default behavior is to print a JSON blob for every single host as the task runs. If you have 100 hosts, your terminal becomes a scrolling nightmare. I wanted a single, formatted report that appeared once the data collection finished.
Next, I'll discuss how we built a production-grade reporting tool, and the good stuff I learned along the way.
Step 1: Solve the unreachable problem
The first lesson I learned from the AI was about variable resilience. If a server is down, Ansible Automation Platform usually drops it from the play. If your reporting logic tries to look up facts for a host that didn't respond, the whole playbook crashes with an UndefinedError.
The trick is to use ansible_play_hosts instead of ansible_play_hosts_all. Use ansible_play_hosts_all for every host in your targeted inventory and ansible_play_hosts for only the hosts that actually responded and succeeded.
Step 2: Use Jinja2 as a logic engine
Most people use Jinja2 for simple variable replacement. But for a report like this, you need it to behave more like a Python script. We used Python-style dictionary methods directly inside a set_fact task to group our data as follows:
{% if ver not in summary %}
{% set _ = summary.update({ver: []}) %}
{% endif %}
{% set _ = summary[ver].append(host) %}By using .update() and .append(), we built a dynamic dictionary where each RHEL version is a key, and the value is a list of hostnames.
Pro tip: The set _ = syntax keeps the template from printing "None" every time it calls a function.
Step 3: Make it picky-coder approved
To make this process ready for publication, we had to look at best practices. For picky coders, that means:
- Using FQCNs: Always use
ansible.builtin.set_factinstead of the short-formset_fact. - Whitespace control: Use
{%-and-%}to strip out unwanted new lines from the Jinja2 logic. - Using the literal scalar
|: Use this pipe character in YAML to ensure the report preserves line breaks in the terminal.
The final solution
The final, audited playbook generates a sorted, hierarchical report and even lists unreachable hosts at the bottom for full transparency.
YAML:
---
- name: Identify RHEL Version
hosts: all
gather_facts: false # Disable default 'all' gathering
tasks:
- name: Gather minimal distribution facts
ansible.builtin.setup:
gather_subset:
- "!all" # Start with nothing
- "min" # Only minimal facts (includes distribution)
- name: "Consolidate RHEL version data into a report"
run_once: true
delegate_to: localhost
ansible.builtin.set_fact:
rhel_report: |
{%- set summary = {} -%}
{%- for host in ansible_play_hosts -%}
{%- set ver = hostvars[host].ansible_facts['distribution_version'] | default('Unknown') -%}
{%- if ver not in summary -%}
{%- set _ = summary.update({ver: []}) -%}
{%- endif -%}
{%- set _ = summary[ver].append(host) -%}
{%- endfor -%}
REPORT: RHEL VERSION DISTRIBUTION
=================================
{% for version in summary.keys() | sort %}
Version: {{ version }}
{% for host in summary[version] | sort %}
- {{ host }}
{% endfor %}
{% endfor %}
{%- set unreachable = ansible_play_hosts_all | difference(ansible_play_hosts) -%}
{%- if unreachable %}
UNREACHABLE HOSTS
=================
{% for host in unreachable | sort %}
- {{ host }}
{% endfor %}
{%- endif %}
- name: "Emit formatted report to console"
run_once: true
delegate_to: localhost
ansible.builtin.debug:
msg: "{{ rhel_report.split('\n') }}"
This is the output from my lab (YAML):
PLAY [Identify RHEL Version] *********************************************
TASK [Gather minimal distribution facts] *********************************
Thursday 26 February 2026 16:03:46 -0500 (0:00:00.004) 0:00:00.004 ok: [node1]
ok: [node2]
ok: [node3]
fatal: [node4]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host node4 port 22: No route to host", "unreachable": true}
fatal: [win1x]: UNREACHABLE! => {"changed": false, "msg": "Data could not be sent to remote host \"win1x\". Make sure this host can be reached over ssh: ssh: connect to host win1x port 22: No route to host\r\n", "unreachable": true}
fatal: [node5]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host node5 port 22: No route to host", "unreachable": true}
TASK [Consolidate RHEL version data into a report] ***********************
Thursday 26 February 2026 16:03:50 -0500 (0:00:03.509) 0:00:03.513 ok: [node1 -> localhost]
TASK [Emit formatted report to console] *********************************
Thursday 26 February 2026 16:03:50 -0500 (0:00:00.020) 0:00:03.533
ok: [node1 -> localhost] => {
"msg": [
"REPORT: RHEL VERSION DISTRIBUTION",
"=================================",
"Version: 9.7",
"- node1",
"- node2",
"",
"Version: 8.10",
"- node3",
"",
"UNREACHABLE HOSTS",
"=================",
"- node4",
"- node5",
"- win1x",
""
]
}
PLAY RECAP ****************************************************************
node1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node3 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
node4 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
node5 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0
win1x : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0Wrap up
What I learned from this AI collaboration wasn't just about syntax; it was about data architecture. Instead of settling for messy logs, we used the control node to aggregate data, handled failures gracefully, and presented the results in a human-readable format. Whether you're auditing RHEL versions or checking kernel levels across a thousand nodes, these grouping and reporting patterns will save you hours of manual investigation. Of course, if I were handling thousands of boxes, I would also send the output to a database or a CSV file. But that was just what I needed initially.