Ansible Gathered, Rendered and Parsed on Cisco IOS XE: Read State Without Changing Anything

Three read only states of the cisco.ios resource modules on IOS XE 17.18: gathered pulls live state into YAML, rendered turns YAML into CLI with no device, parsed reads a saved config. Plus the stale backup and leftover secondary address they caught.

Lab diagram: Three read-only states of the cisco.ios resource modules, left to right. Gathered: hq-core1 (IOS XE 17.18.2) is read over SSH with ios_l3_interfaces / ios_interfaces state: gathe

This is part 8 of the Ansible for network engineers series. Part 3 covered the states that change a device: merged, replaced, overridden and deleted. The same resource modules have three more states that never push a single command. gathered reads live config into structured data. rendered turns structured data into CLI lines without touching a device. parsed reads a saved config file and returns the same structure gathered would.

That makes them the safe way to start automating a network you didn't build. You can read everything, store it as YAML, and compare it before any playbook is allowed to change anything.

Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1 and Python 3.14, against hq-core1 running IOS XE 17.18.2. The inventory is the one from part 2.

Lab diagram: Three read-only states of the cisco.ios resource modules, left to right. Gathered: hq-core1 (IOS XE 17.18.2) is read over SSH with ios_l3_interfaces / ios_interfaces state: gathered, saved to build/gathered/hq-core1.yml, where Ethernet0/2 has description 'CORE: hq-edge1 Et0/1'. Rendered: hq-core1.yml goes through state: rendered on localhost with no device contacted, giving CLI lines such as interface Ethernet0/1, ip address 10.66.101.1 255.255.255.252 and interface loopback0. Parsed: backups/hq-core1-interfaces.cfg goes through ios_interfaces state: parsed on localhost with no device contacted, giving name and description pairs such as Ethernet0/2 CORE: hq-edge1 Et0/2. A drift card compares gathered (live) with parsed (backup): Ethernet0/2 description is live 'CORE: hq-edge1 Et0/1' and backup 'CORE: hq-edge1 Et0/2', so the backup is stale.
Gathered, rendered and parsed, and the drift they exposed on hq-core1

gathered: live state as YAML

# playbooks/80-gathered.yml
---
- name: Read device state as structured data
  hosts: hq-core1
  gather_facts: false
  tasks:
    - name: Gather L3 interfaces
      cisco.ios.ios_l3_interfaces:
        state: gathered
      register: l3

    - name: Gather interfaces
      cisco.ios.ios_interfaces:
        state: gathered
      register: intf

    - name: Save both as YAML
      ansible.builtin.copy:
        content: "{{ {'l3_interfaces': l3.gathered, 'interfaces': intf.gathered} | to_nice_yaml(indent=2) }}"
        dest: "{{ playbook_dir }}/../build/gathered/{{ inventory_hostname }}.yml"
        mode: "0644"
      delegate_to: localhost

The only changed in the run is the file write on the control node. The router reports ok:

TASK [Gather L3 interfaces] ****************************************************
ok: [hq-core1]

TASK [Gather interfaces] *******************************************************
ok: [hq-core1]

TASK [Save both as YAML] *******************************************************
changed: [hq-core1 -> localhost]

PLAY RECAP *********************************************************************
hq-core1                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0

Here's the file it wrote:

interfaces:
- description: OOB management
  enabled: true
  name: Ethernet0/0
- description: 'CORE: hq-core2 Et0/1'
  enabled: true
  name: Ethernet0/1
- description: 'CORE: hq-edge1 Et0/1'
  enabled: true
  name: Ethernet0/2
- description: TEMP lab test - remove
  enabled: false
  name: Ethernet0/3
- description: HQ core loopback
  enabled: true
  name: Loopback0
l3_interfaces:
- ipv4:
  - address: 10.66.10.11/24
  name: Ethernet0/0
- ipv4:
  - address: 10.66.101.1/30
  name: Ethernet0/1
- ipv4:
  - address: 10.66.199.1/24
    secondary: true
  - address: 10.66.102.1/30
  name: Ethernet0/2
- name: Ethernet0/3
- ipv4:
  - address: 10.66.255.11/32
  name: Loopback0

Reading it as data showed two things I'd forgotten on this router: Et0/3 is shut with a "TEMP lab test - remove" description, and Et0/2 still has a 10.66.199.1/24 secondary from an old test. Both were in show run the whole time. I just never looked. In YAML they're one grep away, and on a real network you can loop the same play over every host and search the lot.

Masks come back as prefix lengths (/30, not 255.255.255.252). That's the format the module expects in config:, so this output can go straight back into a replaced task later.

rendered: YAML to CLI with no device

rendered takes the same config: you'd give merged or replaced and returns the commands it would generate. It never opens a session, so it runs on localhost.

# playbooks/81-rendered.yml
---
- name: Render config from data, no device needed
  hosts: localhost
  gather_facts: false
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: cisco.ios.ios
    want: "{{ lookup('file', playbook_dir ~ '/../build/gathered/hq-core1.yml') | from_yaml }}"
  tasks:
    - name: Render L3 interfaces
      cisco.ios.ios_l3_interfaces:
        state: rendered
        config: "{{ want.l3_interfaces }}"
      register: r

    - name: Show it
      ansible.builtin.debug:
        var: r.rendered

The two connection vars look odd for a play that connects to nothing, but my first run without them failed:

Connection type local is not valid for this module

Implicit localhost gets ansible_connection: local, and the module refuses to run under it even though it won't use the connection. A play level connection: keyword didn't help, because the host var wins. Setting both as play vars did.

ok: [localhost] => {
    "r.rendered": [
        "interface Ethernet0/0",
        "ip address 10.66.10.11 255.255.255.0",
        "interface Ethernet0/1",
        "ip address 10.66.101.1 255.255.255.252",
        "interface Ethernet0/2",
        "ip address 10.66.102.1 255.255.255.252",
        "ip address 10.66.199.1 255.255.255.0 secondary",
        "interface loopback0",
        "ip address 10.66.255.11 255.255.255.255"
    ]
}

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

Two details. The prefixes are back to dotted masks, which is what IOS wants. And Loopback0 came out as interface loopback0 in lowercase. IOS accepts that, but if you diff rendered output against show run text, normalize case first or every loopback shows up as a difference.

I use rendered for code review: put the generated lines in the merge request so whoever approves the change reads CLI, not YAML.

parsed: a saved config file, offline

Part 4 saved every router's running config to backups/. parsed reads that text and returns structured data, again with no device.

# playbooks/82-parsed.yml
---
- name: Parse saved config text, no device needed
  hosts: localhost
  gather_facts: false
  vars:
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: cisco.ios.ios
  tasks:
    - name: Parse interfaces from a backup file
      cisco.ios.ios_interfaces:
        state: parsed
        running_config: "{{ lookup('file', playbook_dir ~ '/../backups/hq-core1-interfaces.cfg') }}"
      register: p

    - name: Show name and description
      ansible.builtin.debug:
        msg: "{{ p.parsed | map(attribute='name') | zip(p.parsed | map(attribute='description', default='')) | list }}"

My first attempt pointed it at the full backup file and failed:

Action failed: 'name' is undefined

I cut the file down until it passed. The problem was a VRF definition near the top of the config (name changed here):

vrf definition MGMT
 description management VRF

The interfaces parser picked up that indented description line as if it belonged to an interface, found no interface name to attach it to, and failed. Feeding it only the interface sections fixed it:

awk '/^interface /{p=1} /^!/{p=0} p' backups/hq-core1.cfg > backups/hq-core1-interfaces.cfg
# output compacted to one pair per line
ok: [localhost] => {
    "msg": [
        ["Ethernet0/0", "OOB management"],
        ["Ethernet0/1", "CORE: hq-core2 Et0/1"],
        ["Ethernet0/2", "CORE: hq-edge1 Et0/2"],
        ["Ethernet0/3", "TEMP lab test - remove"],
        ["Loopback0", "HQ core loopback"]
    ]
}

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

Compare that with the gathered file. Live Et0/2 says CORE: hq-edge1 Et0/1. The backup says CORE: hq-edge1 Et0/2. The description was corrected on the router after that backup was taken, so the backup is stale. Nothing in ansible-playbook output warned me; the two data sets only disagreed once they were side by side.

Which one to use

State Talks to device Input Output
gathered yes, read only live config structured data
rendered no structured data CLI lines
parsed no config text structured data

All three report changed=0 for the device, so they're safe in a scheduled job or against production on day one.

What I'd do on a production network

Start with gathered across the whole inventory and commit the YAML to git next to the backups from part 4. Read it. You'll find leftovers like my Et0/3 and that secondary address. Use parsed on old backups when the device is gone or you can't log in, and strip non interface sections first if the parser chokes. Use rendered to show reviewers the exact commands before anything is pushed. Only after the data looks right should it become the config: of a replaced task.

Next in the series: Ansible for Cisco ISE: Network Devices, Device Groups and Endpoints with cisco.ise, managing network devices, device groups and endpoints over the ERS API.

Read next