Ansible Zero Touch Deployment of Cisco ISE

I've been playing around with Ansible a lot lately and mostly with things like compliance checks for network devices, and basic configuration tasks other than my full build of the VXLAN lab which I posted previously and I've seen quite a lot of articles lately about using Ansible for deploying VM's and containers etc so though why not give it a go. I needed to rebuild one of my ISE servers in my lab so thought this would be a great opportunity to try and do it using Ansible. Long story short, I did some research and found that since Cisco ISE version 3.1, they've made a lot of improvements in ZTP deployments of ISE allowing you to deploy the whole thing with a touch of a button. I thought great, let's give it a crack. 

In my lab, I'm using VMware's vCenter server to manage my ESX hosts and will be deploying the ISE OVA version 3.2 as a new VM. Once the VM has been deployed, I'll use Ansible to configure the initial setup configuration settings, apply the latest security patch and then import some certificates. 

There were a few things I had to do on my Ansible server which caught me at first, the first was to update the vmware collection, the second was to install the python module aiohttp so make sure you have those installed/updated first. and the last one, once I had the ZTP deployment working and the VM deployed, when I started configuring ISE, I found I also had to install the ISE SDK using the command pip install ciscoisesdk.

Prepare ZTP Image

Let's get started. Deploying Cisco ISE using ZTP requires you to create a ZTP image file that can be mounted to the VM as a CDROM and then booted. The ZTP image file then gets used for the initial setup of the ISE server so instead of booting the VM and having to use the console to run the initial setup command, this does it automagically for you. To create your ZTP image file, you will need two things, a ZTP configuration file, and the Cisco provided ZTP image creation script for your version of ISE. The ZTP image creation script can be found in the install guide for the specific version of ISE you intend to deploy but if you're using ISE version 3.2 like I am, here is the script.

#!/bin/bash
###########################################################
# This script is used to generate ise ztp image with ztp
# configuration file.
#
# Need to pass ztp configuration file as input.
#
# Copyright (c) 2021 by Cisco Systems, Inc.
# All rights reserved.
# Note:
# To mount the image use below command
# mount ise_ztp_config.img /ztp
# To mount the image from cdrom
# mount -o ro /dev/sr1 /ztp
#############################################################
if [ -z "$1" ];then
echo "Usage:$0 <ise-ztp.conf> [out-ztp.img]"
exit 1
elif [ ! -f $1 ];then
echo "file $1 not exist"
exit 1
else
conf_file=$1
fi
if [ -z "$2" ] ;then
image=ise_config.img
else
image=$2
fi
mountpath=/tmp/ise_ztp
ztplabel=ISE-ZTP
rm -fr $mountpath
mkdir -p $mountpath
dd if=/dev/zero of=$image bs=1k count=1440 > /dev/null 2>&1
if [ `echo $?` -ne 0 ];then
echo "Image creation failed\n"
exit 1
fi
mkfs.ext4 $image -L $ztplabel -F > /dev/null 2>&1
mount -o rw,loop $image $mountpath
cp $conf_file $mountpath/ise-ztp.conf
sync
umount $mountpath
sleep 1
# Check for automount and unmount
automountpath=$(mount | grep $ztplabel | awk '{print $3}')
if [ -n "$automountpath" ];then
umount $automountpath
fi
echo "Image created $image"

Copy the above text and using your favourite editor, paste the script into a file called create_ztp_image.sh and make it executable. Once you have the create image script, create the ISE ZTP configuration file. Once again, using your favourite editor, create a file called ise-ztp.conf and enter the details for your deployment. You can also call this file something else if you're deploying multiple servers. Note that if you don't need a section of this configuration, for example, the values listed as optional, you can just delete them. In the below configuration file, the password must be in clear text which is why in a later step, you'll see that it must be changed at first login.

hostname=<hostname of Cisco ISE>
ipv4_addr=<IPv4 address>
ipv4_mask=<IPv4 subnet>
ipv4_default_gw=<IPv4 gateway address>
#IPv6 optional
#ipv6_addr=<IPv6 address>
#ipv6_default_gw=<IPv6 gateway address>
domain=<cisco.com>
primary_nameserver=<IPv4 address>
#secondary and tertiary are optional
secondary_nameserver=<IPv4 address>
tertiary_nameserver=<IPv4 address>
primary_ntpserver=<IPv4 address or FQDN of the NTP server>
#secondary and tertiary are optional
secondary_ntpserver=<IPv4 address or FQDN of the NTP server>
tertiary_ntpserver=<IPv4 address or FQDN of the NTP server>
timezone=<timezone>
ssh=<true/false>
username=<admin> <--admin is the default for on-prem installations
password=<password>
#Public Key Authentication configuration is optional
public_key=<Public Key>
#Repository Configuration are optional
repository_name=<repository name>
repository_protocol=<repository protocol>
repository_server_name=<IPv4 address>
repository_path=<repository path>
#Patch Information - optional
patch=<patch filename>
#HotPatches Information - optional
hotpatches=<hotpatch filename,comma separated list>
#services - optional
ers=<true/false>
openapi=<true/false>
pxgrid=<true/false>
pxGrid_Cloud=<true/false>
#Skipping specific checks
SkipIcmpChecks=<true/false>
SkipDnsChecks=<true/false>
SkipNtpChecks=<true/false>

With those two files created, you then need to run the following command to actually create your image file. As i mentioned earlier, you can also call this file something else if you're deploying multiple servers. 

ray@wrlab:~/$ sudo ./create_ztp_image.sh ise-ztp.conf ise-ztp.img
[sudo] password for ray:           
Image created ise-ztp.img

You should now have a file called ise-ztp.img. During the Ansible play, you will copy this file to the datastore of the ESX host and mount it as a CDROM before powering on the ISE VM for the first time. 

Prepare Ansible

Now that you have your ZTP files, the next thing to do is make sure you have the Cisco ISE OVA file and any patch files if you're applying them. I have the OVA file copied to the local Ansible server for simplicity but you can do this however you like. For the patch file, I'm using an FTP repo that I have setup locally for my lab. 

For this lab, I created 3 roles, you don't have to do it this way, it's just how it worked in my brain. The first role is the actual VM deployment, the second is to reset the CLI admin password to something different as it's in clear text in the conf file and I want to change it to something more secure, and the last role is for applying the patch and some basic ISE configuration. Role 1 and 2 can be done easily without any input from a user, however before you can perform the tasks in role 3, you have to log into the Web GUI and reset the admin password there too. I'm yet to find a way to do this using Ansible or API calls so please leave a comment if you know how. 

Role 1: ise_deploy_vm
Role 2: ise_reset_ztp_password
Role 3: ise_basic_config

Within the folder structure of the ise_deploy_vm role, I created a files folder and this is where I have the ztp image file saved. You'll need this for the deployment if you're following this guide. Once you have your roles and folder structure sorted, next create the required inventory files. As always, there are many ways to setup your inventory but this is how I have mine. 

inventory.yml

all:
  hosts:
    wrlabvcsa:
      ansible_host: wrlabvcsa.wrlab.local
    wrlabise01:
      ansible_host: 10.99.99.20

  children:
    vcenter:
      hosts:
        wrlabvcsa:

    ise-new:
      hosts:
        wrlabise01:
      vars:
        ansible_become: no
        ansible_connection: ansible.netcommon.network_cli
        ansible_network_os: cisco.ios.ios

Once you have your inventory file, create the following host_vars file for your ISE server

host_vars/wrlabise01.yml

ansible_become: no
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ise_username: admin

ansible_ssh_user: admin
ansible_ssh_password: "{{ ise_admin_pass }}"

ztp_admin_pass: "{{ vault_ztp_admin_pass }}"
ise_admin_pass: "{{ vault_ise_admin_pass }}"

repo_protocol: FTP # Possible options - CDROM|DISK|FTP|SFTP|HTTP|HTTPS|NFS|TFTP
backup_repo_name: LAB-BACKUP
backup_repo_server: 10.99.99.10
backup_repo_path: "/Backup/ISE-Backup/"
backup_repo_user: iseftp
backup_repo_pass: "{{ vault_backup_repo_pass }}"

patch_repo_name: LAB-FTP
patch_repo_server: 10.99.99.10
patch_repo_path: "/Data/FTP/"
patch_repo_user: ise_patch
patch_repo_pass: "{{ vault_patch_repo_pass }}"

patch_file: ise-patchbundle-3.2.0.542-Patch9-25122311.SPA.x86_64.tar.gz
patch_version: "9"

ise_cert_pass: "{{ vault_ise_cert_pass }}"

# This is only required because of the way I ran my roles
vcsa_user: admin
vcsa_passwd: "{{ vault_vcsa_passwd }}"
new_ise_server: WRLABISE01
ise_datacenter: HOME
new_ise_ip: 10.99.99.20

nics:
  - Network adapter 1
  - Network adapter 2
  - Network adapter 3
  - Network adapter 4
  - Network adapter 5
  - Network adapter 6

Once you have the host_vars created, create the following group_vars files

group_vars/vcenter.yml

# General VCSA vars
vcsa_user: admin
vcsa_passwd: "{{ vault_vcsa_passwd }}"


# Deploy ISE Server vars
new_ise_server: WRLABISE01
ise_esxi_host: wrlabesx1.rcmm.local
ise_datastore: ESX1-VM
ztp_datastore: ESX1-DISK1
ise_vm_network: DP_wrlab_svrs
ise_datacenter: WRLAB

That's it for the inventory configuration and required files/folders. Now it's time to create the roles and Ansible tasks. 

Ansible Roles

The first part of this guide will actually deploy the ISE OVA file to your ESX host. Create your role and tasks folder if you're using roles, and then create the main.yml file. You can put the entire play into this file however I use this one to call the other task files. Below is the structure of my ise_deploy_vm role. 

roles
	ise_deploy_vm
		files
			ise-ztp.img
		tasks
			main.yml
			deploy_ise_ovf.yml
			get_new_vm_info.yml
			deploy_ztp_image.yml

Below is my main.yml file, as you can see, this simply calls each of the task files in the desired order. 

---
- name: Deploy new ISE VM using OVF
  include_role:
    name: ise_deploy_vm
    tasks_from: deploy_ise_ovf.yml

- name: Get new ISE server info
  include_role:
    name: ise_deploy_vm
    tasks_from: get_new_vm_info.yml

- name: Attach ZTP image file and power on
  include_role:
    name: ise_deploy_vm
    tasks_from: deploy_ztp_image.yml

The deploy_ise_ovf.yml play first does a simple check on the ESX host to see if the VM already exists, and if not, deploys the ISE VM using the OVA file. Pretty straight forward.

---
- name: Check if new ISE  has already been deployed
  community.vmware.vmware_guest_info:
    hostname: wrlabvcsa.wrlab.local
    username: "{{ vcsa_user }}"
    password: "{{ vcsa_passwd }}"
    validate_certs: no
    datacenter: "{{ ise_datacenter }}"
    name: "{{ new_ise_server }}"
  register: new_ise_server_exists
  connection: local
  ignore_errors: true

- name: Deploy OVF Template
  community.vmware.vmware_deploy_ovf:
    hostname: wrlabvcsa.wrlab.local
    username: "{{ vcsa_user }}"
    password: "{{ vcsa_passwd }}"
    datacenter: "{{ ise_datacenter }}"
    ovf: /home/ray/ISE-3.2.0.542b-virtual-SNS3715-SNS3755-300.ova
    name: "{{ new_ise_server }}"
    deployment_option: Eval
    datastore: "{{ ise_datastore }}"
    esxi_hostname: "{{ ise_esxi_host }}"
    networks: 
      "VM Network": "{{ ise_vm_network }}"
    power_on: no
  connection: local
  when: new_ise_server_exists.instance is not defined or new_ise_server_exists.instance == {}

This is all that's required to deploy the ISE VM using Ansible. If all you want to do is deploy the ISE OVA to create the VM, then you're done, you can stop here. If you want to automated the ISE server initial setup, keep reading. The next task file that runs, get_new_vm_info.yml, grabs the new ISE VM MOID from vCenter. This task is required for the ZTP image task as you need the VM MOID to mount the CDROM.

- name: Get the MOID of the new ISE server
  community.vmware.vmware_vm_info:
    hostname: wrlabvcsa.wrlab.local
    username: "{{ vcsa_user }}"
    password: "{{ vcsa_passwd }}"
    validate_certs: no
    vm_name: "{{ new_ise_server}}"
  register: new_ise_vminfo
  connection: local

- name: Define the new ISE server VM MOID variable
  set_fact:
    new_ise_moid: "{{ new_ise_vminfo.virtual_machines[0].moid }}"

Once that task runs, the next task file will copy the ZTP image file to the ESX datastore, then mount the ZTP image file to the ISE VM, and power it on. Once powered on, the ZTP image file will run and automagically configure the initial setup settings for your ISE server. 

- name: Copy ZTP image to ESXi Datastore
  community.vmware.vsphere_copy:
    hostname: wrlabvcsa.wrlab.local
    username: "{{ vcsa_user }}"
    password: "{{ vcsa_passwd }}"
    validate_certs: no
    src: "{{ role_path }}/files/ise-ztp.img"
    datacenter: "{{ ise_datacenter }}"
    datastore: "{{ ztp_datastore }}"
    path: "/ztp/ise-ztp.img"
  connection: local

- name: Create new CD-ROM device and attach ZTP image to the new ISE Server
  vmware.vmware_rest.vcenter_vm_hardware_cdrom:
    vcenter_hostname: wrlabvcsa.wrlab.local
    vcenter_username: "{{ vcsa_user }}"
    vcenter_password: "{{ vcsa_passwd }}"
    vcenter_validate_certs: false
    vm: "{{ new_ise_moid }}"
    state: present
    cdrom: "{{ new_ise_cdrom }}"
    backing:
      type: ISO_FILE
      iso_file: "[ESX01-DISK1] /ztp/ise-ztp.img"
    start_connected: true
    allow_guest_control: true
  register: cdrom_result
  connection: local

- name: Power on the new ISE server
  community.vmware.vmware_guest_powerstate:
    hostname: wrlabvcsa.wrlab.local
    username: "{{ vcsa_user }}"
    password: "{{ vcsa_passwd }}"
    validate_certs: no
    moid: "{{ new_ise_moid }}"
    state: powered-on
  connection: local
  register: power_on_ise

That's all you need now for a basic deployment of Cisco ISE using ZTP including the initial setup. Once the script has run and the setup completed, you can log in and change the passwords and configure your ISE server. However, I'm lazy so the next role I created is to reset the admin password that was created by the ZTP config file. Below is the folder structure I used for this role. 

roles
	ise_reset_ztp_password
		tasks
			main.yml
			confirm_ise_is_available.yml
			reset_ztp_admin_password.yml

Once again the main.yml file simply calls the other task files in order to run. 

---
- name: Set correct admin password
  include_role:
    name: ise_reset_ztp_password
    tasks_from: confirm_ise_is_available.yml

- name: Update the cli admin password
  include_role:
    name: ise_reset_ztp_password
    tasks_from: reset_ztp_admin_password.yml

Because I'm using a single playbook to run all of these roles and the initial ISE deployment and setup takes about 30 minutes, I use the confirm_ise_is_available tasks to do some basic check to confirm the server is actually up and running. I'm not going to take credit for this script though as I found it online so this isn't something I wrote, only modified it to suit my needs. My understanding of it though is that it will wait a period of time for ports to stop, then wait again for ports to start before finally checking that there is a correct HTTP 200 response from the ISE server. The timers here you can tweak to suit your environment but these seem to work for me. The first task will do a quick check to see if the web GUI of the ISE server is available and if not, it will run the next two tasks to wait a period of time for each service to stop/start before finally waiting for the ISE web GUI to be available again. 

---
- name: Wait for Login | {{ inventory_hostname }} ({{ ansible_host }}) - retry every 2 minutes
  delegate_to: localhost
  ansible.builtin.uri:
    url: https://{{ ansible_host }}/admin/login.jsp
    method: GET
    follow_redirects: safe
    timeout: 10
    validate_certs: no
    return_content: no
  register: result
  retries: 3
  delay: 120
  connection: local
  
- name: Wait for service restart
  delegate_to: localhost
  connection: local
  vars:
    wait_timeout: "{{ 30 * 10 }}" # 300s == 5 minutes per port
  with_items:
    - 22 # SSH
    - 443 # HTTPS
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: "{{ item }}"
    state: stopped # Port is CLOSED
    sleep: 3 # Default: 1. Seconds to sleep between checks
    timeout: 300
  ignore_errors: yes 
  when: result.status != 200
  
- name: Wait for Ports
  delegate_to: localhost
  connection: local
  vars:
    wait_timeout: "{{ 30 * 10 }}" # 300s == 5 minutes per port
  with_items:
    - 22 # SSH
    - 443 # HTTPS
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: "{{ item }}"
    state: started
    sleep: 3
    timeout: "{{ wait_timeout }}"
  ignore_errors: yes
  when: result.status != 200

- name: Wait for Login | {{ inventory_hostname }} ({{ ansible_host }}) - retry every 2 minutes
  delegate_to: localhost
  ansible.builtin.uri:
    url: https://{{ ansible_host }}/admin/login.jsp
    method: GET
    follow_redirects: safe
    timeout: 10
    validate_certs: no
    return_content: no
  register: result2
  until: result2.status == 200
  retries: 40 # 40 * 120 seconds = 4800 = 80 minutes
  delay: 120
  connection: local

This might not be right, but it works and I'm not a programmer so don't know how to tweak it to work any differently. Anyway moving on. The next task file is to reset the CLI admin password. The only reason I'm doing this is because the ZTP configuration file password is all clear text and ISE requires ZTP deployments to change the password anyway. This is why  when I created the file, I used a temp password. Once the ISE server Web GUI is responding with a HTTP response code of 200, this task quite literally logs into the CLI, and resets the admin password. The username and password vars specified in the actual task are required as the username and password specified in the inventory are the ones that I want it to actually be set to not the ZTP ones. 

---
- name: Reset ztp password using CLI
  ansible.netcommon.cli_command:
    command: "password"
    check_all: yes
    prompt:
      - "Enter old password:"
      - "Enter new password:"
      - "Confirm new password:"
    answer:
      - "{{ ztp_admin_pass }}\r"
      - "{{ ise_admin_pass }}\r"
      - "{{ ise_admin_pass }}\r"
  vars:
    ansible_become: no
    ansible_connection: ansible.netcommon.network_cli
    ansible_network_os: cisco.ios.ios
    ansible_ssh_user: admin
    ansible_ssh_password: "{{ ztp_admin_pass }}"
  register: ztp_pass_reset_result
  ignore_errors: true

- name: Display Result
  debug:
    var: ztp_pass_reset_result.stdout_lines

- name: Wait for confirmation web password reset
  ansible.builtin.pause:
    prompt: "Log into the web GUI and reset admin password. Press Enter to continue or Ctrl+C to abort."

The last task in this role, prompts you to log into the Web GUI of the server and reset the Web admin password. This is required when you deploy ISE using ZTP as when you first log in, it forces you to change the password. As I mentioned earlier, I've not found a way around this yet or a way to automate it so added this task to pause the play until I'd done that so that the rest of the play could run. You could also exclude this task and separate your deployment and configuration plays and only run the configuration playbook after you had logged in and reset the password. 

The last part of my script creates a couple of repos, upgrades the server patch version and updates the Web Admin and EAP certificates. 

roles
	ise_upgrade_and_configure
		tasks
			main.yml
			create_backup_repo.yml
			create_patch_repo.yml
			apply_latest_patch.yml
			add_required_certs.yml

Once again, I use the main.yml file to call the other task files in order. 

---
- name: Configure repository for backups
  include_role:
    name: ise_configure_new_vm
    tasks_from: create_backup_repo.yml

- name: Configure repository for applying patch
  include_role:
    name: ise_configure_new_vm
    tasks_from: create_patch_repo.yml

- name: Apply the latest patch
  include_role:
    name: ise_configure_new_vm
    tasks_from: apply_latest_patch.yml

- name: Import the required certificates
  include_role:
    name: ise_configure_new_vm
    tasks_from: add_required_certs.yml

The first task creates the backup repo. This isn't required for anything other than enabling backups later on. I didn't have to do this here it was more for my own curiosity. All of these variables are set in the host_vars for the ISE server.

---
- name: Create Backup Repository | {{ backup_repo_name }} @ {{ inventory_hostname }} ({{ ansible_host }})
  cisco.ise.repository:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    state: present
    protocol: "{{ repo_protocol | upper }}"
    name: "{{ backup_repo_name }}"
    serverName: "{{ backup_repo_server }}"
    path: "{{ backup_repo_path }}"
    userName: "{{ backup_repo_user }}"
    password: "{{ backup_repo_pass }}"
  register: backup_repository

The next task is actually required as it will configure the repo used to apply the patch. In saying that, the first task in this example isn't required for this play as the cisco.ise.repository Ansible module creates the repo in the Web GUI which doesn't create it in the CLI, hence why I have the second task to create the repo via CLI. 

---
- name: Create Web GUI Patch Repository | {{ patch_repo_name }} @ {{ inventory_hostname }} ({{ ansible_host }})
  cisco.ise.repository:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    state: present
    protocol: "{{ repo_protocol | upper }}"
    name: "{{ patch_repo_name }}"
    serverName: "{{ patch_repo_server }}"
    path: "{{ patch_repo_path }}"
    userName: "{{ patch_repo_user }}"
    password: "{{ patch_repo_pass }}"
  register: patch_repository

- name: Create Patch Repository for CLI
  ansible.netcommon.cli_config:
    config: |
      repository {{ patch_repo_name }}
        url ftp://{{ patch_repo_server }}{{ patch_repo_path }}
        user {{ patch_repo_user }} password plain {{ patch_repo_pass }}
  register: cli_patch_repo

Once you have the repo's created, the next task will apply the patch file. It first checks if there are any patches already applied and determines if there is a difference in the already installed patch version and the desired patch version. If there is, the patch is applied and once again, I have the little wait script run because once a patch is applied, the ISE services restart.

---
- name: Get Patch Info | {{ item }}
  delegate_to: localhost
  cisco.ise.patch_info:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
  register: patch_info

- name: Define patch version variable if patch is installed
  set_fact:
    curr_patch_ver: "{{ patch_info.ise_response.patchVersion[0].patchNumber }}"
  when: patch_info.ise_response.patchVersion[0].patchNumber is defined

- name: Define the patch version as 0 if no patch version is installed
  set_fact:
    no_curr_patch: 0
  when: curr_patch_ver is not defined

- name: Update patch only when required
  block:
    - name: Patch Install | {{ patch_file }}
      cisco.ise.patch_install:
        ise_hostname: "{{ ansible_host }}"
        ise_username: "{{ ise_username }}"
        ise_password: "{{ ise_admin_pass }}"
        ise_verify: false
        repositoryName: "{{ patch_repo_name }}"
        patchName: "{{ patch_file }}"
      register: patch_status
      failed_when: patch_status.failed
    
    - name: Patch Install Message
      ansible.builtin.debug:
        msg: "{{ patch_status.ise_response.response.message }}"
      when: patch_status is defined
     
    - name: Wait for service restart
      delegate_to: localhost
      connection: local
      vars:
        wait_timeout: "{{ 30 * 5 }}" # 150s == 2.5 minutes per port
      with_items:
        - 443 # HTTPS
        - 22 # SSH
      ansible.builtin.wait_for:
        host: "{{ ansible_host }}"
        port: "{{ item }}"
        state: stopped 
        sleep: 3 
        timeout: "{{ wait_timeout }}" 
      ignore_errors: yes
    
    - name: Wait for Ports
      delegate_to: localhost
      connection: local
      vars:
        wait_timeout: "{{ 30 * 5 }}" # 150 == 2.5 minutes per port
      with_items:
        - 22 # SSH
        - 443 # HTTPS
      ansible.builtin.wait_for:
        host: "{{ ansible_host }}"
        port: "{{ item }}"
        state: started
        sleep: 3
        timeout: "{{ wait_timeout }}"
      ignore_errors: yes
    
    - name: Wait for Login | {{ inventory_hostname }} ({{ ansible_host }})
      delegate_to: localhost
      ansible.builtin.uri:
        url: https://{{ ansible_host }}/admin/login.jsp
        method: GET
        follow_redirects: safe
        timeout: 10
        validate_certs: no
        return_content: no
      register: result
      until: result.status == 200
      retries: 180 # 180 * 10 seconds = 1800s = 30 minutes
      delay: 10
      connection: local
  when: no_curr_patch is defined or curr_patch_ver != patch_version

Once the patch has been applied and the services started and web GUI is reachable, the last part of this role installs my lab CA and ICA certificates before importing the Admin and EAP certificates. The Admin and EAP certs are ones that I had exported from the server I'm rebuilding. The certificate import tasks took me a bit to get working because there are a number of required options that weren't in the Ansible documentation. I also couldn't work out how to just import the certificate files, which is why there are the tasks to read the pem file as a string. And once again, because replacing the EAP certificate requires some ISE services to restart, I have the little wait script run to tell Ansible when the server is available again. 

---
- name: Read CA certificate file into a variable
  set_fact:
    ca_cert_string: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/CA.pem') }}"

- name: Read ICA certificate file into a variable
  set_fact:
    ica_cert_string: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/ICA.pem') }}"

- name: Import Trusted Root CA Certificates
  cisco.ise.trusted_certificate_import:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    allowBasicConstraintCAFalse: true
    allowOutOfDateCert: false
    allowSHA1Certificates: true
    name: "CA"
    data: "{{ ca_cert_string }}" # PEM formatted data
    description: "CA certificate"
    trustForIseAuth: True
    trustForClientAuth: True
  ignore_errors: true

- name: Import Trusted Intermediary CA Certificates
  cisco.ise.trusted_certificate_import:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    allowBasicConstraintCAFalse: true
    allowOutOfDateCert: false
    allowSHA1Certificates: true
    name: "ICA"
    data: "{{ ica_cert_string }}" # PEM formatted data
    description: "ICA certificate"
    trustForIseAuth: True
    trustForClientAuth: True
  ignore_errors: true

- name: Import ISE Server Admin cert
  set_fact:
    admin_cert: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/ISEADMIN.pem') }}"

- name: Import ISE Server Admin key
  set_fact:
    admin_key: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/ISEADMIN.pvk') }}"

- name: Import ISE Server EAP cert
  set_fact:
    eap_cert: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/ISEEAP.pem') }}"

- name: Import ISE Server EAP key
  set_fact:
    eap_key: "{{ lookup('ansible.builtin.file', '{{ role_path }}/files/ISEEAP.pvk') }}"

- name: Import Web Admin certificate
  cisco.ise.system_certificate_import:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    admin: true
    allowExtendedValidity: true
    allowOutOfDateCert: false
    allowReplacementOfCertificates: false
    allowReplacementOfPortalGroupTag: false
    allowRoleTransferForSameSubject: true
    allowSHA1Certificates: true
    allowWildCardCertificates: false
    allowPortalTagTransferForSameSubject: false
    data: "{{ admin_cert }}"
    name: ISE-Admin
    password: "{{ ise_cert_pass }}"
    privateKeyData: "{{ admin_key }}"
    validateCertificateExtensions: false
  ignore_errors: true
  register: admin_cert_replaced

- name: Import EAP certificate
  cisco.ise.system_certificate_import:
    ise_hostname: "{{ ansible_host }}"
    ise_username: "{{ ise_username }}"
    ise_password: "{{ ise_admin_pass }}"
    ise_verify: false
    allowExtendedValidity: true
    allowOutOfDateCert: false
    allowReplacementOfCertificates: false
    allowReplacementOfPortalGroupTag: false
    allowRoleTransferForSameSubject: true
    allowSHA1Certificates: true
    allowWildCardCertificates: false
    allowPortalTagTransferForSameSubject: false
    data: "{{ eap_cert }}"
    eap: true
    name: ISE-EAP
    password: "{{ ise_cert_pass }}"
    privateKeyData: "{{ eap_key }}"
    validateCertificateExtensions: false
  ignore_errors: true
  register: eap_cert_replaced

- name: Wait for service restart
  delegate_to: localhost
  connection: local
  vars:
    wait_timeout: "{{ 30 * 5 }}" # 150s == 2.5 minutes per port
  with_items:
    - 443 # SSH/REST/API Gateway
    - 22 # SSH
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: "{{ item }}"
    state: stopped
    sleep: 3
    timeout: "{{ wait_timeout }}"
  ignore_errors: yes
  when: admin_cert_replaced.changed or eap_cert_replaced.changed
    
- name: Wait for Ports
  delegate_to: localhost
  connection: local
  vars:
    wait_timeout: "{{ 30 * 5 }}" # 150 == 2.5 minutes per port
  with_items:
    - 22 # SSH
    - 443 # SSH/REST/API Gateway
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: "{{ item }}"
    state: started
    sleep: 3
    timeout: "{{ wait_timeout }}"
  ignore_errors: yes
  when: admin_cert_replaced.changed or eap_cert_replaced.changed

- name: Wait for Login | {{ inventory_hostname }} ({{ ansible_host }})
  delegate_to: localhost
  ansible.builtin.uri:
    url: https://{{ ansible_host }}/admin/login.jsp
    method: GET
    follow_redirects: safe
    timeout: 10
    validate_certs: no
    return_content: no
  register: result
  until: result.status == 200
  retries: 180 # 180 * 10 seconds = 1800s = 30 minutes
  delay: 10
  connection: local

And that's it. That is how to perform a very simple and basic ZTP deployment of Cisco ISE using Ansible. 

Thanks for checking out my blog. If you've noticed anything missing or have any questions, please leave a comment and let me know.

Tags

Add new comment