Ansible for Cisco ISE: Network Devices, Device Groups and Endpoints with cisco.ise

Network device groups, five network devices with a vaulted RADIUS secret, and an endpoint identity group with static MACs on ISE 3.5, all with cisco.ise 3.3.0. Includes the SDK schema mismatch that breaks network_device and a drift test.

Lab diagram: An Ansible control node (ansible-core 2.21.5, cisco.ise 3.3.0, ciscoisesdk 2.4.7) reads vars/ise-data.yml and the vault-encrypted ise-inv/group_vars/ise/vault.yml and ise-inv/gro

This is part 9 of the Ansible for network engineers series. Parts 1 to 8 pushed config to routers, switches and an ASA over SSH. Cisco ISE is different. There's no CLI to template, so the cisco.ise collection talks to the ISE REST APIs instead. This post uses it for the three things every ISE rollout keeps re-typing in the GUI: network device groups, network devices with their RADIUS secret, and an endpoint identity group with a few static MAC addresses.

Built in my home lab with ansible-core 2.21.5, cisco.ise 3.3.0, ciscoisesdk 2.4.7 and Python 3.14, against Cisco ISE 3.5. If you've seen my ISE 11007 troubleshooting post, this is how you stop that error from happening in the first place: the device exists in ISE because the playbook put it there.

Lab diagram: An Ansible control node (ansible-core 2.21.5, cisco.ise 3.3.0, ciscoisesdk 2.4.7) reads vars/ise-data.yml and the vault-encrypted ise-inv/group_vars/ise/vault.yml and ise-inv/group_vars/ise/secrets.yml, then calls Cisco ISE 3.5 at ise1.example.com over HTTPS, ERS API (local connection), with network_device pinned to the 3.3_patch_1 schema. ISE gets Network Device Groups (Location: HQ, BR1, BR2; Device Type: Switches; Routers already existed), Network Devices hq-core1, hq-edge1, br1-edge1, br1-acc1 and br2-edge1 with the RADIUS secret from vault, and Endpoint Identity Group PL-Printers with 00:50:56:AA:10:01 and 00:50:56:AA:10:02. On the right, the five devices send RADIUS to ISE, which now recognises them (no 11007): hq-core1 10.66.10.11 HQ Switches, hq-edge1 10.66.10.13 HQ Routers, br1-edge1 10.66.10.21 BR1 Routers, br1-acc1 10.66.10.22 BR1 Switches, br2-edge1 10.66.10.31 BR2 Routers. Callout: no_log, because ERS returns the shared secret in plain text.
Ansible pushes device groups, devices and PL-Printers to ISE over ERS

Setup: SDK, API access and inventory

The collection is a thin wrapper around a Python SDK that isn't pulled in automatically:

ansible-galaxy collection install cisco.ise
pip install ciscoisesdk

ISE needs its APIs on. On my node ERS and OpenAPI were already enabled under Administration > System > Settings > API Settings, so I didn't change anything. If yours aren't, enable both there. Use an account with the ERS Admin role. In production give Ansible its own admin rather than borrowing a person's login.

The modules run on the control node and make HTTPS calls, so ISE gets its own inventory with a local connection:

# ise-inv/hosts.yml
all:
  children:
    ise:
      hosts:
        ise1:
          ansible_host: ise1.example.com
# ise-inv/group_vars/ise/connection.yml
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"
ise_hostname: "{{ ansible_host }}"
ise_username: "{{ vault_ise_username }}"
ise_password: "{{ vault_ise_password }}"
ise_verify: false
ise_version: "3.5.0"

ansible_python_interpreter matters. Without it Ansible may pick a system Python that doesn't have ciscoisesdk installed. ise_verify: false is for a lab with a self-signed certificate; in production point it at a trusted cert instead.

The vault files hold the admin login and the RADIUS shared secret. In the post they're placeholders:

# ise-inv/group_vars/ise/vault.yml (encrypted)
vault_ise_username: "<ers-admin-user>"
vault_ise_password: "<ers-admin-password>"
# ise-inv/group_vars/ise/secrets.yml (encrypted)
vault_radius_secret: "<radius-shared-secret>"

One more thing: cisco.ise doesn't ship a module_defaults group, so you can't set the five connection options once for every module. I used a YAML anchor per play, which you'll see below.

The data

Everything ISE should know lives in one vars file. The playbooks only loop over it.

# vars/ise-data.yml
ise_groups:
  - {name: "Location#All Locations#HQ", type: Location, description: "Headquarters"}
  - {name: "Location#All Locations#BR1", type: Location, description: "Branch 1"}
  - {name: "Location#All Locations#BR2", type: Location, description: "Branch 2"}
  - {name: "Device Type#All Device Types#Switches", type: Device Type, description: "Access and core switches"}

ise_devices:
  - {name: hq-core1, ip: 10.66.10.11, location: HQ, type: Switches}
  - {name: hq-edge1, ip: 10.66.10.13, location: HQ, type: Routers}
  - {name: br1-edge1, ip: 10.66.10.21, location: BR1, type: Routers}
  - {name: br1-acc1, ip: 10.66.10.22, location: BR1, type: Switches}
  - {name: br2-edge1, ip: 10.66.10.31, location: BR2, type: Routers}

ise_endpoint_group: {name: PL-Printers, description: "Printers allowed by MAC (MAB)"}
ise_endpoints:
  - {mac: "00:50:56:AA:10:01", description: "br1 floor printer"}
  - {mac: "00:50:56:AA:10:02", description: "hq lobby printer"}

Read first

Before writing anything I read what ISE already had. This node had devices and groups from earlier labs, and I didn't want a playbook fighting them.

# playbooks/70-ise-read.yml
---
- name: Read what ISE already has
  hosts: ise
  gather_facts: false
  module_defaults:
    cisco.ise.network_device_group_info: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
    cisco.ise.network_device_info: *ise
  tasks:
    - name: Network device groups
      cisco.ise.network_device_group_info:
      register: ndg
    - name: Network devices
      cisco.ise.network_device_info:
      register: nd
    - name: Summary
      ansible.builtin.debug:
        msg:
          groups: "{{ ndg.ise_response | map(attribute='name') | list }}"
          devices: "{{ nd.ise_response | map(attribute='name') | list }}"
ok: [ise1] => {
    "msg": {
        "devices": [
            "acc-sw1",
            "acc-sw2",
            "acc-sw3",
            "acc-sw6",
            "core-rtr5",
            "edge-fw7"
        ],
        "groups": [
            "Device Type#All Device Types",
            "Device Type#All Device Types#Firewalls",
            "Device Type#All Device Types#Routers",
            "IPSEC#Is IPSEC Device",
            "IPSEC#Is IPSEC Device#No",
            "IPSEC#Is IPSEC Device#Yes",
            "Location#All Locations"
        ]
    }
}

Routers already existed, Switches and the three locations didn't. Group names are the full path with # separators, and that's the format every other module expects too.

A word on device types. In the rest of this series hq-core1 is the HQ core router, an IOS XE Layer 3 image, and br1-acc1 is the only real Layer 2 switch. Putting hq-core1 under Switches is a grouping choice in this data, not a statement about the box. If your policy sets match on device type, file it under Routers instead; that's a one word change in vars/ise-data.yml.

Network device groups

# playbooks/71-ise-ndg.yml
---
- name: Network device groups
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.network_device_group: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
  tasks:
    - name: Location and device type groups
      cisco.ise.network_device_group:
        state: present
        name: "{{ item.name }}"
        othername: "{{ item.type }}"
        description: "{{ item.description }}"
      loop: "{{ ise_groups }}"
      loop_control:
        label: "{{ item.name }}"

othername is the group type (the root of the tree). I ran it with --check first, and that tells you something about this collection:

TASK [Location and device type groups] *****************************************
skipping: [ise1] => (item=Location#All Locations#HQ) 
skipping: [ise1] => (item=Location#All Locations#BR1) 
skipping: [ise1] => (item=Location#All Locations#BR2) 
skipping: [ise1] => (item=Device Type#All Device Types#Switches) 
skipping: [ise1]

The cisco.ise modules don't support check mode. They skip, so a dry run tells you nothing about what would change. Read first with the _info modules instead, as above. The real run and the rerun:

changed: [ise1] => (item=Location#All Locations#HQ)
changed: [ise1] => (item=Location#All Locations#BR1)
changed: [ise1] => (item=Location#All Locations#BR2)
changed: [ise1] => (item=Device Type#All Device Types#Switches)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0
ok: [ise1] => (item=Location#All Locations#HQ)
ok: [ise1] => (item=Location#All Locations#BR1)
ok: [ise1] => (item=Location#All Locations#BR2)
ok: [ise1] => (item=Device Type#All Device Types#Switches)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=0    unreachable=0    failed=0    skipped=0
Network Device Groups expanded, showing BR1, BR2, HQ and Switches created by the playbook with their device counts
Network Device Groups expanded, showing BR1, BR2, HQ and Switches created by the playbook with their device counts

Network devices, and the schema bug

# playbooks/72-ise-devices.yml
---
- name: Network devices
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.network_device: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      # the SDK 3.5.0 schema demands authenticationSettings.enabled; ISE 3.5 rejects it
      ise_version: 3.3_patch_1
  tasks:
    - name: Add the lab routers and switches
      cisco.ise.network_device:
        state: present
        name: "{{ item.name }}"
        description: "Managed by Ansible"
        profileName: Cisco
        NetworkDeviceIPList:
          - {ipaddress: "{{ item.ip }}", mask: 32}
        NetworkDeviceGroupList:
          - "Location#All Locations#{{ item.location }}"
          - "Device Type#All Device Types#{{ item.type }}"
          - "IPSEC#Is IPSEC Device#No"
        authenticationSettings:
          networkProtocol: RADIUS
          dtlsRequired: false
          radiusSharedSecret: "{{ vault_radius_secret }}"
          enableKeyWrap: false
          keyEncryptionKey: ""
          keyInputFormat: ASCII
      loop: "{{ ise_devices }}"
      loop_control:
        label: "{{ item.name }}"
      no_log: "{{ hide_secrets | default(true) }}"

With ise_version: "3.5.0" the SDK validates the payload against its 3.5.0 schema before sending it. That schema requires authenticationSettings.enabled and coaPort. Add enabled to satisfy the SDK and ISE 3.5 rejects the request with a "JSON invalidity" error. Leave it out and the SDK refuses to send at all. On top of that the module turns coaPort into a float (1700.0), which the older schema rejects because it wants an integer.

What worked was pinning this one module to the 3.3_patch_1 schema and leaving coaPort out completely. ISE fills in its default of 1700. The rest of the playbooks stay on 3.5.0. I found the combination by creating and deleting a throwaway device through the API until a payload was accepted, which is a good habit before you point a loop at five real ones.

Also note IPSEC#Is IPSEC Device#No. ISE adds it to every device by default, so I list it to keep the desired state identical to what ISE returns.

changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
changed: [ise1] => (item=(censored due to no_log))
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0

The rerun came back with changed=0 and every item ok.

Network Devices list in ISE 3.5 with the five Ansible managed devices, each described as Managed by Ansible
Network Devices list in ISE 3.5 with the five Ansible managed devices, each described as Managed by Ansible
hq-edge1 detail page in ISE: description, IP 10.66.10.13/32, Cisco profile and the HQ location group
hq-edge1 detail page in ISE: description, IP 10.66.10.13/32, Cisco profile and the HQ location group

Why no_log is not optional here

The ERS API returns the RADIUS shared secret in plain text when you read a device. The module compares it with what you sent, which is how idempotency works, but it also means the secret ends up in the task result. Without no_log it lands in your terminal, your CI log and any callback plugin. I made it a variable so I can turn it off while debugging a non secret field (-e hide_secrets=false), and it defaults to on.

Because the module compares the secret, it also catches a rotation. I ran the play once with a different test secret: one device reported changed, and the next normal run put the vaulted secret back. That's what you want when the vault is the source of truth.

Drift from the GUI

To check it catches GUI edits, I changed hq-edge1's description to "Edited in the GUI" by hand, then reran the play with hide_secrets=false so I could see which item moved:

TASK [Add the lab routers and switches] ****************************************
changed: [ise1] => (item=hq-edge1)
PLAY RECAP *********************************************************************
ise1                       : ok=1    changed=1    unreachable=0    failed=0    skipped=0

The description went back to "Managed by Ansible", and the following run was changed=0 again.

Endpoint identity group and static endpoints

# playbooks/73-ise-endpoints.yml
---
- name: Endpoint group and endpoints
  hosts: ise
  gather_facts: false
  vars_files: [../vars/ise-data.yml]
  module_defaults:
    cisco.ise.endpointgroup: &ise
      ise_hostname: "{{ ise_hostname }}"
      ise_username: "{{ ise_username }}"
      ise_password: "{{ ise_password }}"
      ise_verify: "{{ ise_verify }}"
      ise_version: "{{ ise_version }}"
    cisco.ise.endpointgroup_info: *ise
    cisco.ise.endpoint: *ise
  tasks:
    - name: Endpoint identity group
      cisco.ise.endpointgroup:
        state: present
        name: "{{ ise_endpoint_group.name }}"
        description: "{{ ise_endpoint_group.description }}"
        systemDefined: false
    - name: Look up its id
      cisco.ise.endpointgroup_info:
        name: "{{ ise_endpoint_group.name }}"
      register: epg
    - name: Endpoints, statically assigned to the group
      cisco.ise.endpoint:
        state: present
        mac: "{{ item.mac }}"
        name: "{{ item.mac }}"
        description: "{{ item.description }}"
        groupId: "{{ epg.ise_response.id }}"
        staticGroupAssignment: true
        staticProfileAssignment: false
      loop: "{{ ise_endpoints }}"
      loop_control:
        label: "{{ item.mac }}"

Two details. systemDefined: false is required even though you'd think it's obvious for a group you're creating. And endpoints reference the group by id, not name, so the play looks the id up between the two tasks.

TASK [Endpoint identity group] *************************************************
changed: [ise1]
TASK [Look up its id] **********************************************************
ok: [ise1]
TASK [Endpoints, statically assigned to the group] *****************************
changed: [ise1] => (item=00:50:56:AA:10:01)
changed: [ise1] => (item=00:50:56:AA:10:02)
PLAY RECAP *********************************************************************
ise1                       : ok=3    changed=2    unreachable=0    failed=0    skipped=0

The rerun was changed=0 with all three tasks ok.

Endpoint identity group PL-Printers with both MAC addresses statically assigned
Endpoint identity group PL-Printers with both MAC addresses statically assigned

ISE assigned the VMWare-Device profile on its own because the MACs I used are in a VMware OUI. With staticProfileAssignment: false that's expected. Set it to true and add a profileId if you want to pin the profile too.

What I'd do in production

Give Ansible its own ERS admin and keep that password and every RADIUS secret in vault. Read with the _info modules before every change, because check mode does nothing here. Keep no_log on anything that touches authenticationSettings. Pin ise_version per module when the SDK schema and your ISE patch disagree, and leave a comment saying why so the next person doesn't "fix" it. Rerun after every change and expect changed=0.

Next in the series: VLANs, trunks and SVIs on IOS XE from one data model with the resource modules.

Read next