Ansible Resource Module States on IOS XE: merged vs replaced vs overridden vs deleted

I ran the same ios_l3_interfaces data through merged, replaced, overridden and deleted on an IOS XE 17.18 router and captured the exact commands each state sent. One of them would have removed the management IP. Another quietly turned off ICMP redirects and unreachables.

Lab diagram: The same YAML input, vars/hq-core1-l3.yml, feeds four ios_l3_interfaces states, each with the commands it sends to IOS XE. merged sends interface Ethernet0/1, ipv6 address 2001:d

The state option on a Cisco resource module decides what Ansible removes, and the module docs describe it in one line each. I wanted to see the actual commands, so I took one router with some messy hand-built interface config, fed the same YAML to cisco.ios.ios_l3_interfaces four times, and reset the router between runs.

Two results surprised me. overridden was about to delete the management IP on Ethernet0/0, the address Ansible was connected through. And replaced added no ip redirects and no ip unreachables to every interface it touched, even though my YAML never mentioned ICMP.

This is part 3 of the Ansible for Network Engineers series. It reuses the inventory from part 2 and the control node from part 1. I built it in my home lab on Cisco IOS XE 17.18.2, with ansible-core 2.21.5 and cisco.ios 11.6.0. All output is from real runs.

The starting point

hq-core1 is the HQ core router from the earlier posts. Its management port, Ethernet0/0, is 10.66.10.11/24 in a management VRF. The three other ports look like a router that people have been editing by hand for a while:

Lab diagram: The same YAML input, vars/hq-core1-l3.yml, feeds four ios_l3_interfaces states, each with the commands it sends to IOS XE. merged sends interface Ethernet0/1, ipv6 address 2001:db8:66:101::1/64. replaced sends no ip redirects and no ip unreachables. overridden (focal) in check mode would send no ip address 10.66.10.11 255.255.255.0 on Ethernet0/0, the mgmt IP. deleted on Ethernet0/3 sends no ip address 10.66.250.1 255.255.255.0; deleted with no config list resets every interface.
What each ios_l3_interfaces state sends for the same intent file
interface Ethernet0/1
 description to hq-core2
 ip address 10.66.101.1 255.255.255.252
interface Ethernet0/2
 description spare - old WAN
 ip address 10.66.199.1 255.255.255.0 secondary
 ip address 10.66.102.1 255.255.255.252
interface Ethernet0/3
 description TEMP lab test - remove
 ip address 10.66.250.1 255.255.255.0
 shutdown

Ethernet0/2 still carries a secondary address from an old migration, and Ethernet0/3 has a test subnet someone forgot about. I kept a small playbook, 20-seed-brownfield.yml, that pushes exactly this config with ios_config, so every test below starts from the same state.

The intent file

This is what I want the two core-facing ports to look like. Ethernet0/1 gains an IPv6 address. Ethernet0/2 keeps only its /30.

# vars/hq-core1-l3.yml
l3_intent:
  - name: Ethernet0/1
    ipv4:
      - address: 10.66.101.1/30
    ipv6:
      - address: 2001:db8:66:101::1/64
  - name: Ethernet0/2
    ipv4:
      - address: 10.66.102.1/30

The file doesn't list Ethernet0/0 or Ethernet0/3. That omission is the whole difference between the four states.

One playbook runs it, with the state passed on the command line, and prints the commands list the module returns. That list is the most honest diff you'll get from a resource module: it's what went down the SSH session.

---
- name: ios_l3_interfaces with a chosen state
  hosts: hq-core1
  gather_facts: false
  vars_files:
    - ../vars/{{ l3_file | default('hq-core1-l3.yml') }}
  tasks:
    - name: L3 interfaces, state={{ st }}
      cisco.ios.ios_l3_interfaces:
        config: "{{ l3_intent }}"
        state: "{{ st }}"
      register: result

    - name: Commands Ansible sent (or would send)
      ansible.builtin.debug:
        var: result.commands

I also tried --diff. With these modules it printed nothing extra for me, so result.commands plus a show run before and after is how I compared the runs.

merged: add, never remove

$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=merged --diff

TASK [L3 interfaces, state=merged] *********************************************
changed: [hq-core1]

TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
    "result.commands": [
        "interface Ethernet0/1",
        "ipv6 address 2001:db8:66:101::1/64"
    ]
}

One line. The secondary address on Ethernet0/2 is still there, because merged only adds what's missing. It treats the YAML as "at least this", so leftovers survive. A second run sent nothing and reported changed=0.

Use merged when you're adding something to a brownfield device and you don't yet trust your data to describe everything on it.

replaced: own the listed interfaces

$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=replaced

TASK [L3 interfaces, state=replaced] *******************************************
changed: [hq-core1]

TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
    "result.commands": [
        "interface Ethernet0/1",
        "no ip redirects",
        "no ip unreachables",
        "no ipv6 redirects",
        "no ipv6 unreachables",
        "ipv6 address 2001:db8:66:101::1/64",
        "interface Ethernet0/2",
        "no ip redirects",
        "no ip unreachables",
        "no ipv6 redirects",
        "no ipv6 unreachables",
        "no ip address 10.66.199.1 255.255.255.0 secondary"
    ]
}

The secondary address is gone, which is what replaced is for: for every interface in the YAML, the YAML is the complete L3 config. Ethernet0/0 and Ethernet0/3 weren't touched.

The ICMP lines were the part I didn't expect. My YAML didn't set redirects or unreachables, and the module read "not set" as "false". The running config afterwards:

interface Ethernet0/1
 description to hq-core2
 ip address 10.66.101.1 255.255.255.252
 no ip redirects
 no ip unreachables
 ipv6 address 2001:DB8:66:101::1/64
 no ipv6 redirects
 no ipv6 unreachables

On a core link that may even be what you want, since plenty of hardening guides turn redirects off. But with unreachables off, the router stops answering with ICMP unreachable messages, and traceroute through it gets harder to read. Ping Fails: ICMP Troubleshooting shows how much those messages tell you during an outage. It should be a decision you make on purpose, so I put the values in the data:

  - name: Ethernet0/1
    redirects: true
    unreachables: true
    ipv4:
      - address: 10.66.101.1/30
    ipv6:
      - address: 2001:db8:66:101::1/64
TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
    "result.commands": [
        "interface Ethernet0/1",
        "ip redirects",
        "ip unreachables",
        "interface Ethernet0/2",
        "ip redirects",
        "ip unreachables"
    ]
}

That fixed IPv4 only. The IPv6 lines stayed no ipv6 redirects and no ipv6 unreachables until I also set ipv6_redirects and ipv6_unreachables, which are separate keys. The second run of the fixed file reported changed=0.

overridden: own the whole device

Before running overridden for real I ran it with --check. I'm glad I did:

$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=overridden --check

TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
    "result.commands": [
        "interface Ethernet0/0",
        "no ip address 10.66.10.11 255.255.255.0",
        "interface Ethernet0/3",
        "no ip address 10.66.250.1 255.255.255.0",
        "interface Ethernet0/1",
        "ipv6 address 2001:db8:66:101::1/64",
        "interface Ethernet0/2",
        "no ip address 10.66.199.1 255.255.255.0 secondary"
    ]
}

The first two commands remove the address of the management interface. overridden means "the YAML is the complete L3 config for the device", and the YAML didn't list Ethernet0/0, so the module planned to clear it. The play would have cut its own SSH session partway through, and I'd have been on the console.

A management VRF doesn't protect you here. The module doesn't look at VRFs; it saw an interface with an address that wasn't in the data.

The fix is to put the management interface in the data. With Ethernet0/0 listed (vars/hq-core1-l3-full.yml), the real run removed only the forgotten test subnet and the old secondary:

    "result.commands": [
        "interface Ethernet0/3",
        "no ip address 10.66.250.1 255.255.255.0",
        "interface Ethernet0/0",
        "no ip redirects",
        "no ip unreachables",
        "no ipv6 redirects",
        "no ipv6 unreachables",
        "interface Ethernet0/1",
        "ipv6 address 2001:db8:66:101::1/64",
        "interface Ethernet0/2",
        "no ip address 10.66.199.1 255.255.255.0 secondary"
    ]

Look at Ethernet0/0 again: the ICMP defaults from the replaced section hit the management port too. Ethernet0/3 kept its description and its shutdown, since ios_l3_interfaces only manages L3 attributes. Descriptions and admin state belong to ios_interfaces, which has the same four states.

deleted: with and without a config list

With an interface named, deleted removes that interface's L3 config and nothing else:

    - name: Remove whatever L3 config Ethernet0/3 has
      cisco.ios.ios_l3_interfaces:
        config:
          - name: Ethernet0/3
        state: deleted
    "result.commands": [
        "interface Ethernet0/3",
        "no ip address 10.66.250.1 255.255.255.0"
    ]

Without config, deleted applies to every interface on the device. I only ran this one in check mode, as an ad-hoc command:

$ ansible -i site-inv hq-core1 -m cisco.ios.ios_l3_interfaces -a "state=deleted" --check
[
 "interface Ethernet0/0",
 "ip redirects",
 "ip unreachables",
 "ipv6 redirects",
 "ipv6 unreachables",
 "no ip address 10.66.10.11 255.255.255.0",
 "interface Ethernet0/1",
 "ipv6 redirects",
 "ipv6 unreachables",
 "no ip address 10.66.101.1 255.255.255.252",
 "interface Ethernet0/2",
 "ipv6 redirects",
 "ipv6 unreachables",
 "no ip address 10.66.199.1 255.255.255.0 secondary",
 "no ip address 10.66.102.1 255.255.255.252"
]

Every address on the router, management included. One forgotten config: key turns a cleanup task into an outage, so I never write state: deleted without a config list, and I'd catch it in review the way I'd catch no router ospf in a change ticket.

Note that deleted puts the ICMP settings back to their IOS defaults (ip redirects, ip unreachables), while replaced with no values set turned them off. The two states read a missing key differently.

What each state sent, side by side

State Scope Ethernet0/1 Ethernet0/2 secondary Ethernet0/3 test subnet Ethernet0/0 mgmt IP
merged listed interfaces, add only IPv6 added kept kept kept
replaced listed interfaces, full replace IPv6 added, ICMP off removed kept kept
overridden whole device IPv6 added removed removed removed unless listed
deleted (one interface) listed interfaces untouched untouched removed kept
deleted (no config) whole device removed removed removed removed

How I use them now

  • Brownfield, first contact: merged. It can't delete anything.
  • Interfaces I fully own in the data: replaced, with redirects and unreachables (and the IPv6 versions) set explicitly so the module doesn't pick for me.
  • overridden only after the data describes every interface on the box, management port included, and only after a --check run whose commands list I've read.
  • deleted always with a config list.

Getting that complete data set is the next problem. Typing it by hand for six devices is how you leave out Ethernet0/0. The gathered state reads the running config into the same YAML structure, and the next posts in the series use it as the starting point. For the playbook basics behind all of this, start with the series overview.

Read next