> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Ansible Compliance Checks on IOS XE: Find Drift, Fix It, Prove It
- URL: https://www.pinglabz.com/ansible-ios-compliance-drift-remediation/
- Published: 2026-10-31T23:17:24.000Z
- Updated: 2026-11-01T04:41:35.000Z
- Description: Five compliance rules checked on six IOS XE devices against group_vars intent, using gathered resource modules and one running config. Injected drift is found, a fix playbook removes it with check mode first, and the recheck passes all 30 rows.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

This is part 11 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). [Part 10](https://www.pinglabz.com/ansible-ios-vlans-trunks-svis-resource-modules/) built a campus from one data model. This one asks the opposite question for the six routers and switches from the earlier parts: does what's running still match what the inventory says it should be? One playbook checks five rules on every device and writes a report. A second playbook fixes what the first one found. Then the check runs again and has to come back clean.

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 Cisco IOS XE 17.18.2 (router and L2 lab images).

[![Lab diagram: a compliance loop run by Ansible against six Cisco IOS XE 17.18.2 devices. Stage 1, 90-compliance.yml check, takes inventory intent (ntp_server, syslog_server, role_syslog_servers, site_location) and device reads (ios_ntp_global and ios_logging_global gathered, show running-config), applies five rules (ntp-servers, syslog-hosts, snmp-location, http-server-off, vty-ssh-only) and writes reports/compliance.md. A teal arrow leads to stage 2, 91-remediate.yml fix, marked --check first, with example lines from the check run: no ntp server 10.66.0.200, no logging host 10.66.0.99, no ip http server; the apply changed the four drifted devices. A teal arrow leads to stage 3, recheck, showing 30/30 pass against 25/30 pass with 5 FAIL on the first run. A dashed arrow from stage 3 back to stage 1 is labelled on a schedule. Below, six device tiles compare the first run with the recheck: routers hq-core1 and hq-edge1 pass 5/5; router hq-core2 is marked FAULT in orange for ntp-servers and syslog-hosts; routers br1-edge1 and br2-edge1 and switch br1-acc1 are marked FAULT for http-server-off. All six pass on the recheck. Teal means pass, orange with an X means fail or drift.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ios-compliance-loop.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-ios-compliance-loop.png?ref=pinglabz.com)

Check, fix, check again: five rules on six devices

## The rules

Intent lives where it already lived in this series, in group\_vars and host\_vars. Nothing new to maintain:

| Rule            | Passes when                                                                 |
| --------------- | --------------------------------------------------------------------------- |
| ntp-servers     | The only NTP server is the site's ntp\_server                               |
| syslog-hosts    | Syslog hosts are exactly the site syslog\_server plus role\_syslog\_servers |
| snmp-location   | snmp-server location matches site\_location                                 |
| http-server-off | No ip http server line                                                      |
| vty-ssh-only    | Every VTY transport input is ssh                                            |

HQ uses NTP 10.66.0.123 and syslog 10.66.0.51\. The branches use NTP 10.66.20.123, branch 1 sends syslog to its own collector at 10.66.21.51, and the edge role adds a second collector at 10.66.0.52.

"Exactly" matters for the first two rules. A check that only asks "is my server configured?" passes a router that also points at a rogue NTP server. Comparing the sorted lists catches extras as well as missing entries.

## Setting up the drift

To have something to find, I broke four devices on purpose:

- `ip http server` on br1-edge1, br2-edge1 and br1-acc1, the kind of thing that comes back after someone pastes a vendor config.
- On hq-core2, an extra `ntp server 10.66.0.200` and `logging host 10.66.0.99`, next to the correct ones.

hq-core1 and hq-edge1 were left alone, so they should pass.

## The check playbook

NTP and logging come from the resource modules in `gathered` state, which return parsed data instead of text. The other three rules are simple enough to pull out of one `show running-config` with a regex.

```yaml
# playbooks/90-compliance.yml
---
- name: Compliance check against inventory intent
  hosts: sites
  gather_facts: false
  tasks:
    - name: Read NTP config as structured data
      cisco.ios.ios_ntp_global:
        state: gathered
      register: ntp

    - name: Read logging config as structured data
      cisco.ios.ios_logging_global:
        state: gathered
      register: logg

    - name: Read the running config once for line rules
      cisco.ios.ios_command:
        commands: show running-config
      register: run

    - name: Evaluate the rules
      ansible.builtin.set_fact:
        results:
          - rule: ntp-servers
            want: "{{ [ntp_server] }}"
            have: "{{ ntp.gathered.servers | default([]) | map(attribute='server') | list }}"
          - rule: syslog-hosts
            want: "{{ [syslog_server] + role_syslog_servers }}"
            have: "{{ logg.gathered.hosts | default([]) | map(attribute='host') | list }}"
          - rule: snmp-location
            want: "{{ [site_location] }}"
            have: "{{ cfg | regex_findall('(?m)^snmp-server location (.*)$') }}"
          - rule: http-server-off
            want: []
            have: "{{ cfg | regex_findall('(?m)^ip http server$') }}"
          - rule: vty-ssh-only
            want: ["ssh"]
            have: "{{ cfg | regex_findall('(?m)^ transport input (.*)$') | unique }}"
      vars:
        cfg: "{{ run.stdout[0] }}"

    - name: Flag each rule
      ansible.builtin.set_fact:
        report: "{{ report | default([]) + [item | combine({'ok': (item.want | sort) == (item.have | sort)})] }}"
      loop: "{{ results }}"
      loop_control:
        label: "{{ item.rule }}"

    - name: Show failures for this device
      ansible.builtin.debug:
        msg: "{{ report | rejectattr('ok') | map(attribute='rule') | list }}"

    - name: Write one report for the run
      ansible.builtin.template:
        src: ../templates/compliance.md.j2
        dest: ../reports/compliance.md
        mode: "0644"
      delegate_to: localhost
      run_once: true

    - name: Fail the device if anything drifted
      ansible.builtin.assert:
        that: report | rejectattr('ok') | list | length == 0
        fail_msg: "{{ report | rejectattr('ok') | list | length }} rule(s) out of compliance"
        success_msg: all rules pass
        quiet: true

```

Each rule is a `want` list and a `have` list. That keeps the comparison the same for every rule and makes the report readable, because it shows both sides. The `assert` at the end marks a drifted device as failed, so the play recap alone tells you who is out of compliance, and a pipeline running this gets a non zero exit code.

The report template:

```jinja
{# templates/compliance.md.j2 #}
# Compliance report

| Device | Rule | Result | Want | Have |
|---|---|---|---|---|
{% for h in ansible_play_hosts | sort %}
{% for r in hostvars[h].report %}
| {{ h }} | {{ r.rule }} | {{ "pass" if r.ok else "FAIL" }} | {{ r.want | join(", ") }} | {{ r.have | join(", ") }} |
{% endfor %}
{% endfor %}

```

`run_once` with `delegate_to: localhost` writes one file for the whole run, looping over `ansible_play_hosts`, instead of six devices racing to write the same file.

## First run: the drift shows up

```bash
ansible-playbook -i site-inv/hosts.yml playbooks/90-compliance.yml

```

```text
PLAY RECAP *********************************************************************
br1-acc1                   : ok=6    changed=0    unreachable=0    failed=1
br1-edge1                  : ok=6    changed=0    unreachable=0    failed=1
br2-edge1                  : ok=6    changed=0    unreachable=0    failed=1
hq-core1                   : ok=8    changed=1    unreachable=0    failed=0
hq-core2                   : ok=6    changed=0    unreachable=0    failed=1
hq-edge1                   : ok=7    changed=0    unreachable=0    failed=0

```

The `changed=1` on hq-core1 is the report file being written on the control node. `run_once` picks the first host, so the change is booked against it. Nothing touched the router.

The failing rows from `reports/compliance.md`, plus hq-core1 for comparison:

| Device    | Rule            | Result | Want                  | Have                     |
| --------- | --------------- | ------ | --------------------- | ------------------------ |
| br1-acc1  | http-server-off | FAIL   |                       | ip http server           |
| br1-edge1 | http-server-off | FAIL   |                       | ip http server           |
| br2-edge1 | http-server-off | FAIL   |                       | ip http server           |
| hq-core1  | ntp-servers     | pass   | 10.66.0.123           | 10.66.0.123              |
| hq-core1  | syslog-hosts    | pass   | 10.66.0.51            | 10.66.0.51               |
| hq-core1  | snmp-location   | pass   | HQ data room, rack A3 | HQ data room, rack A3    |
| hq-core1  | http-server-off | pass   |                       |                          |
| hq-core1  | vty-ssh-only    | pass   | ssh                   | ssh                      |
| hq-core2  | ntp-servers     | FAIL   | 10.66.0.123           | 10.66.0.123, 10.66.0.200 |
| hq-core2  | syslog-hosts    | FAIL   | 10.66.0.51            | 10.66.0.51, 10.66.0.99   |

All four broken devices were found, with the extra servers on hq-core2 listed by address. The 25 rows also show every rule passing on hq-core1 and hq-edge1, which is the other half of the proof: the check isn't just failing everything.

## The remediation playbook

```yaml
# playbooks/91-remediate.yml
---
- name: Fix what the compliance check flagged
  hosts: sites
  gather_facts: false
  tasks:
    - name: Read NTP config as structured data
      cisco.ios.ios_ntp_global:
        state: gathered
      register: ntp

    - name: Read logging config as structured data
      cisco.ios.ios_logging_global:
        state: gathered
      register: logg

    - name: Build the fix lines from the difference
      ansible.builtin.set_fact:
        fix_lines: "{{ ntp_add + ntp_del + log_add + log_del + http }}"
      vars:
        ntp_have: "{{ ntp.gathered.servers | default([]) | map(attribute='server') | list }}"
        log_have: "{{ logg.gathered.hosts | default([]) | map(attribute='host') | list }}"
        log_want: "{{ [syslog_server] + role_syslog_servers }}"
        ntp_add: "{{ [ntp_server] | difference(ntp_have) | map('regex_replace', '^', 'ntp server ') | list }}"
        ntp_del: "{{ ntp_have | difference([ntp_server]) | map('regex_replace', '^', 'no ntp server ') | list }}"
        log_add: "{{ log_want | difference(log_have) | map('regex_replace', '^', 'logging host ') | list }}"
        log_del: "{{ log_have | difference(log_want) | map('regex_replace', '^', 'no logging host ') | list }}"
        http: ["no ip http server"]

    - name: Show the plan
      ansible.builtin.debug:
        var: fix_lines

    - name: Push the fix lines
      cisco.ios.ios_config:
        lines: "{{ fix_lines }}"

```

The fix lines come from the same gathered data as the check, using `difference` both ways: add what's wanted and missing, remove what's there and not wanted. `no ip http server` goes in every plan. `ios_config` compares it with the running config and only sends it where `ip http server` is present.

Check mode first:

```bash
ansible-playbook -i site-inv/hosts.yml playbooks/91-remediate.yml --check --diff

```

```text
ok: [hq-core2] => {
    "fix_lines": [
        "no ntp server 10.66.0.200",
        "no logging host 10.66.0.99",
        "no ip http server"
    ]
}

br1-acc1                   : ok=5    changed=1    unreachable=0    failed=0
br1-edge1                  : ok=5    changed=1    unreachable=0    failed=0
br2-edge1                  : ok=5    changed=1    unreachable=0    failed=0
hq-core1                   : ok=5    changed=0    unreachable=0    failed=0
hq-core2                   : ok=5    changed=1    unreachable=0    failed=0
hq-edge1                   : ok=5    changed=0    unreachable=0    failed=0

```

Four devices would change, the two clean ones wouldn't. Then the real run:

```text
TASK [Push the fix lines] ******************************************************
ok: [hq-core1]
ok: [hq-edge1]
changed: [br1-acc1]
changed: [hq-core2]
changed: [br1-edge1]
changed: [br2-edge1]

```

You'll also see a warning that the input lines should look like they do in the running config. That's `ios_config` noticing `no ...` lines, which never appear in a running config. It's expected for removals.

## Recheck

```text
PLAY RECAP *********************************************************************
br1-acc1                   : ok=7    changed=0    unreachable=0    failed=0
br1-edge1                  : ok=7    changed=0    unreachable=0    failed=0
br2-edge1                  : ok=7    changed=0    unreachable=0    failed=0
hq-core1                   : ok=8    changed=1    unreachable=0    failed=0
hq-core2                   : ok=7    changed=0    unreachable=0    failed=0
hq-edge1                   : ok=7    changed=0    unreachable=0    failed=0

```

No failures on any of the six devices, and the new report has 30 rows, all pass. hq-core1's `changed=1` is the report file again.

## Reliability notes

My lab host was under load during these runs. I kept `ansible_command_timeout: 120` because a full running config took about a minute to come back at the worst point, and this playbook reads it on every device. I also switched `ansible_network_cli_ssh_type` to `paramiko` after libssh sessions kept stalling mid play, which I covered in part 10\. Even then, the first check run lost br1-acc1 to a connect timeout. A failed connection isn't compliance data, so I reran the whole check rather than trusting a report with a device missing. If you schedule this, treat `unreachable` or a timeout as its own alert, separate from drift.

## What I'd do in production

Run the check on a schedule and keep the reports in Git so you can see when drift started. Keep the fix playbook separate and run it with `--check` first, by hand or behind an approval. Some drift is a person fixing an outage at 2 a.m., and you want to read it before you undo it. Add rules one at a time, and make each one compare exact lists so extras fail too.

[Part 12](https://www.pinglabz.com/ansible-parse-cli-textfsm-ttp-ios/) turns show command output into data with cli\_parse. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).