> ## 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.

# STP Root, Portfast and LACP EtherChannel with Ansible on Cisco IOS XE
- URL: https://www.pinglabz.com/ansible-stp-etherchannel-lacp-ios/
- Published: 2026-11-01T02:07:37.000Z
- Updated: 2026-11-01T04:41:41.000Z
- Description: The root bridge was an access switch nobody picked. One data model now sets the root, portfast with BPDU guard and LACP uplinks, and a check playbook catches a wrong root or a missing bundle member.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

Spanning tree and EtherChannel are two things that are easy to get mostly right by hand and then forget about. The root bridge ends up wherever the lowest MAC address happens to be, an access port without portfast waits for 30 seconds before it forwards, and a bundle with one member missing still says it is up. This post puts all three in the campus data model from [part 10](https://www.pinglabz.com/ansible-ios-vlans-trunks-svis-resource-modules/) and adds a check that fails when any of them is wrong.

It was built in my home lab on three IOS XE L2 lab switches: hq-dist1 and two access switches, hq-acc1 and hq-acc2\. Each access switch has two uplinks to hq-dist1, and there is one extra link between the two access switches, so there is a loop for spanning tree to deal with. The previous post was [part 15, the CDP and LLDP neighbor map](https://www.pinglabz.com/ansible-cdp-lldp-neighbor-map-ios/).

Versions: ansible-core 2.21, cisco.ios 11.6.

[![Lab diagram: Three IOS XE switches after the change. hq-dist1 is the spanning tree root with priority 4096. It bundles Et0/1 and Et1/0 into Po1 LACP to Et0/1 and Et1/0 on hq-acc1, and Et0/2 and Et1/1 into Po2 LACP to Et0/1 and Et1/0 on hq-acc2, each bundle with a cost of 56. The Et1/1 link between the access switches is blocked at hq-acc2 as Altn BLK, and the Et0/2 and Et0/3 desk ports on both access switches are edge ports with portfast and BPDU guard.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-4.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/diagram-topology-4.png?ref=pinglabz.com)

After the change: hq-dist1 is root, two LACP bundles at cost 56, and the access-to-access link blocked at hq-acc2.

## Where the root was

Before any changes, the check playbook further down reported this for VLAN 10:

```text
TASK [Show it] *****************************************************************
ok: [hq-dist1] => {
    "msg": [
        "root for VLAN 10: False",
        "Et0/1               Root FWD 100       128.2    P2p ",
        "Et0/2               Desg FWD 100       128.3    P2p"
    ]
}
ok: [hq-acc1] => {
    "msg": [
        "root for VLAN 10: True",
        "Et0/1               Desg FWD 100       128.2    P2p ",
        "Et0/2               Desg FWD 100       128.3    P2p"
    ]
}
ok: [hq-acc2] => {
    "msg": [
        "root for VLAN 10: False",
        "Et0/1               Root FWD 100       128.2    P2p ",
        "Et0/2               Desg FWD 100       128.3    P2p ",
        "Et0/3               Desg FWD 100       128.4    P2p"
    ]
}

```

hq-acc1 is the root bridge. Nobody chose that. All three switches have the default priority, and hq-acc1 has the lowest MAC address, so it won. Traffic from hq-acc2 to anything behind hq-acc1 is fine here, but on a real campus this is how user traffic ends up crossing an access switch to reach the core. The second uplinks are not doing anything either, and the check fails because the bundles it expects do not exist:

```text
[ERROR]: Task failed: Action failed: Po1 is not up with 2 members
failed: [hq-dist1] (item=Po1) => {"ansible_loop_var": "item", "assertion": "po_lines | select('search', 'Po' ~ item.id ~ '\\\\(SU\\\\)') | list | leng ...
failed: [hq-acc1] (item=Po1) => {"ansible_loop_var": "item", "assertion": "po_lines | select('search', 'Po' ~ item.id ~ '\\\\(SU\\\\)') | list | lengt ...
[ERROR]: Task failed: Action failed: Po2 is not up with 2 members
failed: [hq-acc2] (item=Po2) => {"ansible_loop_var": "item", "assertion": "po_lines | select('search', 'Po' ~ item.id ~ '\\\\(SU\\\\)') | list | lengt ...
failed: [hq-dist1] (item=Po2) => {"ansible_loop_var": "item", "assertion": "po_lines | select('search', 'Po' ~ item.id ~ '\\\\(SU\\\\)') | list | leng ...

```

## The data model

This file sits next to `vars/campus.yml` from part 10\. The access ports in that file (mode access) are the ones that get portfast.

```yaml
---
# Part 16 additions to the campus data model
stp_mode: rapid-pvst
stp_root:
  primary: hq-dist1        # priority 4096 on all campus VLANs
stp_vlans: [10, 20, 30, 90]

port_channels:
  hq-dist1:
    - {id: 1, members: [Ethernet0/1, Ethernet1/0], desc: "PO: hq-acc1 Po1"}
    - {id: 2, members: [Ethernet0/2, Ethernet1/1], desc: "PO: hq-acc2 Po2"}
  hq-acc1:
    - {id: 1, members: [Ethernet0/1, Ethernet1/0], desc: "UPLINK PO: hq-dist1 Po1"}
  hq-acc2:
    - {id: 2, members: [Ethernet0/1, Ethernet1/0], desc: "UPLINK PO: hq-dist1 Po2"}

# access-to-access trunk: a deliberate loop, STP should block one end
extra_trunks:
  hq-acc1: [{name: Ethernet1/1, desc: "TRUNK: hq-acc2 Et1/1"}]
  hq-acc2: [{name: Ethernet1/1, desc: "TRUNK: hq-acc1 Et1/1"}]

```

## The playbook

```yaml
---
- name: STP root, edge ports and LACP uplinks on the HQ campus
  hosts: campus
  gather_facts: false
  vars_files:
    - ../vars/campus.yml
    - ../vars/campus-l2.yml
  vars:
    my_pos: "{{ port_channels[inventory_hostname] | default([]) }}"
    my_trunks: "{{ extra_trunks[inventory_hostname] | default([]) }}"
    edge_ports: "{{ ports[inventory_hostname] | selectattr('mode', 'eq', 'access') | map(attribute='name') | list }}"
    trunk_def:
      encapsulation: dot1q
      allowed_vlans: "{{ trunk_vlans | map('string') | list }}"
      native_vlan: "{{ native_vlan }}"
  tasks:
    - name: Data model into module input
      ansible.builtin.set_fact:
        lag_cfg: >-
          {%- set out = [] -%}
          {%- for po in my_pos -%}
          {%- set _ = out.append({'name': 'Port-channel' ~ po.id, 'members': []}) -%}
          {%- for m in po.members -%}{%- set _ = out[-1].members.append({'member': m, 'mode': 'active'}) -%}{%- endfor -%}
          {%- endfor -%}
          {{ out }}
        trunk_names: "{{ (my_pos | map(attribute='members') | flatten) + (my_pos | map(attribute='id') | map('regex_replace', '^', 'Port-channel') | list) + (my_trunks | map(attribute='name') | list) }}"
        desc_cfg: >-
          {%- set out = [] -%}
          {%- for po in my_pos -%}{%- set _ = out.append({'name': 'Port-channel' ~ po.id, 'description': po.desc, 'enabled': true}) -%}{%- endfor -%}
          {%- for t in my_trunks -%}{%- set _ = out.append({'name': t.name, 'description': t.desc, 'enabled': true}) -%}{%- endfor -%}
          {{ out }}

    - name: Members, bundles and the extra link are trunks with the same settings
      cisco.ios.ios_l2_interfaces:
        config: >-
          {%- set out = [] -%}
          {%- for n in trunk_names -%}{%- set _ = out.append({'name': n, 'mode': 'trunk', 'trunk': trunk_def}) -%}{%- endfor -%}
          {{ out }}
        state: replaced
      register: r_l2

    - name: Bundle the members with LACP active
      cisco.ios.ios_lag_interfaces:
        config: "{{ lag_cfg }}"
        state: merged
      register: r_lag
      when: lag_cfg | length > 0

    - name: Descriptions, ports enabled
      cisco.ios.ios_interfaces:
        config: "{{ desc_cfg }}"
        state: merged
      register: r_desc

    - name: STP mode everywhere, root priority on the distribution switch
      cisco.ios.ios_config:
        lines: >-
          {{ ['spanning-tree mode ' ~ stp_mode]
          + (['spanning-tree vlan ' ~ stp_vlans | join(',') ~ ' priority 4096'] if inventory_hostname == stp_root.primary else []) }}
      register: r_stp

    - name: Portfast and BPDU guard on every access port
      cisco.ios.ios_config:
        parents: "interface {{ item }}"
        lines:
          - spanning-tree portfast
          - spanning-tree bpduguard enable
      loop: "{{ edge_ports }}"
      register: r_edge

    - name: Commands sent
      ansible.builtin.debug:
        msg: >-
          {{ (r_l2.commands | default([])) + (r_lag.commands | default([])) + (r_desc.commands | default([]))
          + (r_stp.updates | default([])) + (r_edge.results | map(attribute='updates', default=[]) | flatten) }}

```

Some notes on it:

- The member ports get their trunk settings before they join the bundle. My first version did it the other way round, and the dry run showed `channel-group` going out before `switchport mode trunk` on the new ports. A member that does not match the bundle gets suspended, so the order matters.
- There is no resource module for spanning tree in cisco.ios, so the root priority and the edge port settings go through `ios_config`. The bundles and trunks use the resource modules.
- Priority 4096 is set explicitly rather than with `spanning-tree vlan ... root primary`. The `root primary` macro reads the current root's priority once and picks a lower one. It does not keep the switch as root if someone else is changed later, and the running config shows a number anyway.
- `bpduguard` goes with `portfast`. Portfast makes a port forward straight away, which is what you want for a desk. If somebody plugs a switch into that desk port, BPDU guard shuts the port instead of letting it join the tree.

The run sent this to hq-dist1:

```text
TASK [Commands sent] ***********************************************************
ok: [hq-dist1] => {
    "msg": [
        "interface Ethernet1/0",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Ethernet1/1",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Port-channel1",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Port-channel2",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Ethernet0/1",
        "channel-group 1 mode active",
        "interface Ethernet1/0",
        "channel-group 1 mode active",
        "interface Ethernet1/1",
        "channel-group 2 mode active",
        "interface Ethernet0/2",
        "channel-group 2 mode active",
        "interface Port-channel1",
        "description PO: hq-acc1 Po1",
        "interface Port-channel2",
        "description PO: hq-acc2 Po2",
        "spanning-tree vlan 10,20,30,90 priority 4096"
    ]
}
ok: [hq-acc2] => {
    "msg": [
        "interface Ethernet1/0",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Port-channel2",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Ethernet1/1",
        "switchport trunk encapsulation dot1q",
        "switchport mode trunk",
        "switchport trunk native vlan 999",
        "switchport trunk allowed vlan 10,20,30,90",
        "interface Ethernet0/1",
        "channel-group 2 mode active",
        "interface Ethernet1/0",
        "channel-group 2 mode active",
        "interface Ethernet1/1",
        "description TRUNK: hq-acc1 Et1/1",
        "interface Port-channel2",
        "description UPLINK PO: hq-dist1 Po2",
        "interface Ethernet0/2",
        "spanning-tree portfast",
        "spanning-tree bpduguard enable",
        "interface Ethernet0/3",
        "spanning-tree portfast",
        "spanning-tree bpduguard enable"
    ]
}

```

and this to hq-acc1:

```text
ok: [hq-acc1]

```

## The check

Reading `show etherchannel summary` and `show spanning-tree vlan 10` and keeping only the lines that matter is enough here. No parser needed.

```yaml
---
- name: Check bundles and spanning tree on the HQ campus
  hosts: campus
  gather_facts: false
  vars_files:
    - ../vars/campus-l2.yml
  vars:
    my_pos: "{{ port_channels[inventory_hostname] | default([]) }}"
  tasks:
    - name: Read the state
      cisco.ios.ios_command:
        commands:
          - show etherchannel summary
          - show spanning-tree vlan 10
      register: r

    - name: Keep the lines that matter
      ansible.builtin.set_fact:
        po_lines: "{{ r.stdout[0].splitlines() | select('search', '^\\d+\\s+Po') | list }}"
        is_root: "{{ 'This bridge is the root' in r.stdout[1] }}"
        stp_ports: "{{ r.stdout[1].splitlines() | select('search', '^(Et|Po)\\S+\\s+(Root|Desg|Altn|Back)') | list }}"

    - name: Show it
      ansible.builtin.debug:
        msg: "{{ ['root for VLAN 10: ' ~ is_root] + po_lines + stp_ports }}"

    - name: Every bundle is up (SU) and every member is bundled (P)
      ansible.builtin.assert:
        that:
          - po_lines | select('search', 'Po' ~ item.id ~ '\\(SU\\)') | list | length == 1
          - po_lines | select('search', 'Po' ~ item.id ~ '\\(') | first | regex_findall('\\(P\\)') | length == item.members | length
        fail_msg: "Po{{ item.id }} is not up with {{ item.members | length }} members"
        quiet: true
      loop: "{{ my_pos }}"
      loop_control:
        label: "Po{{ item.id }}"

    - name: The distribution switch is the root, and nobody else is
      ansible.builtin.assert:
        that: is_root == (inventory_hostname == stp_root.primary)
        fail_msg: "root bridge in the wrong place (this bridge is root: {{ is_root }})"
        quiet: true

```

After the change and 45 seconds for LACP and spanning tree to settle:

```text
TASK [Show it] *****************************************************************
ok: [hq-dist1] => {
    "msg": [
        "root for VLAN 10: True",
        "1      Po1(SU)         LACP        Et0/1(P)        Et1/0(P)        ",
        "2      Po2(SU)         LACP        Et0/2(P)        Et1/1(P)",
        "Po1                 Desg FWD 56        128.65   P2p ",
        "Po2                 Desg FWD 56        128.66   P2p"
    ]
}
ok: [hq-acc1] => {
    "msg": [
        "root for VLAN 10: False",
        "1      Po1(SU)         LACP        Et0/1(P)        Et1/0(P)",
        "Et0/2               Desg FWD 100       128.3    P2p Edge ",
        "Et1/1               Desg FWD 100       128.6    P2p ",
        "Po1                 Root FWD 56        128.65   P2p"
    ]
}
ok: [hq-acc2] => {
    "msg": [
        "root for VLAN 10: False",
        "2      Po2(SU)         LACP        Et0/1(P)        Et1/0(P)",
        "Et0/2               Desg FWD 100       128.3    P2p Edge ",
        "Et0/3               Desg FWD 100       128.4    P2p Edge ",
        "Et1/1               Altn BLK 100       128.6    P2p ",
        "Po2                 Root FWD 56        128.65   P2p"
    ]
}

TASK [Every bundle is up (SU) and every member is bundled (P)] *****************
ok: [hq-dist1] => (item=Po1)
ok: [hq-acc1] => (item=Po1)
ok: [hq-acc2] => (item=Po2)
ok: [hq-dist1] => (item=Po2)

TASK [The distribution switch is the root, and nobody else is] *****************
ok: [hq-dist1]
ok: [hq-acc1]
ok: [hq-acc2]

PLAY RECAP *********************************************************************
hq-acc1                    : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
hq-acc2                    : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
hq-dist1                   : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

```

Everything you would want to see is in there:

- hq-dist1 is the root, and both bundles are designated ports on it.
- Each bundle is `SU` (layer 2, in use) with both members `P` (bundled).
- The link between the access switches is blocked at hq-acc2 (`Altn BLK`). That is the loop, handled.
- The desk ports show `Edge`, which is portfast working.
- Each bundle has a spanning tree cost of 56 against 100 for a single port, because the cost follows the combined bandwidth.

## Running it again

A second run should change nothing. It did change nothing, but I could not get one clean run across all three switches: on each of three runs one switch timed out on SSH and the other two reported `changed=0`. Across the runs, every switch reported `changed=0` at least once. The timeouts are a lab problem, not a playbook problem, but it is worth saying because an unattended job against real devices will meet the same thing, and a run that failed on one host should be retried before anyone trusts it.

## A member goes missing

To check that the check is worth having, I shut `Ethernet1/0` on hq-acc1, one of the two members of Po1, and ran it again 20 seconds later.

```text
        "1      Po1(SU)         LACP        Et0/1(P)        Et1/0(P)        ",
        "1      Po1(SU)         LACP        Et0/1(P)        Et1/0(D)",

```

The first line is hq-dist1, the second is hq-acc1\. hq-acc1 shows the member down (`D`) and the check fails:

```text
failed: [hq-acc1] (item=Po1) => {"ansible_loop_var": "item", "assertion": "po_lines | select('search', 'Po' ~ item.id ~ '\\\\(') | first | regex_finda ...

```

hq-dist1 still showed both members bundled. In this lab, shutting a port on one side does not take the link down on the other, so hq-dist1 only finds out when LACP stops hearing from the neighbor, and with the default slow timer that takes up to 90 seconds. Po1 stayed up the whole time, as it should, at half the bandwidth. That is why the check runs on both ends of every bundle: one side can be wrong for a minute and a half.

After `no shutdown` and 45 seconds, the check passed on all three switches again.

## Where to take it

- Add `lacp rate fast` on the members if you want a dead member noticed in seconds instead of a minute and a half.
- Put a second distribution switch in the model with priority 8192 as the backup root.
- Run the check on a schedule. A root bridge in the wrong place does not cause an outage, so nothing else will tell you.

[Part 17](https://www.pinglabz.com/ansible-port-security-ios/) is a port security baseline for every access port. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).