| description | Use Ansible to configure IOS XE Cisco routers |
|---|---|
| lang | en |
| tags | m1, devnet, cisco, iosxe, linux, ansible, lab15 |
[toc]
In this lab, you will explore the basics of using Ansible to automate backup and interface addressing tasks. First, you will configure Ansible in your DevNet virtual machine. Next, you will use Ansible to connect to the virtual router and back up its configuration. Then you will configure this virtual router with IPv6 addressing and discover that achieving idempotency in automation can come at a significant cost.
After completing the steps in this document, you will be able to:
- Configure Ansible in a DevNet virtual machine environment
- Establish SSH connectivity between the DevNet VM and a virtual router
- Automate router configuration tasks using Ansible playbooks
- Apply Ansible to configure IPv6 addressing on a router interface
- Understand and implement idempotency in Ansible playbooks
- Refactor playbook tasks to achieve better idempotency
A primary objective of this lab is to demonstrate that by separating the addressing logic into variables, the same approach can be applied across various labs and topologies without altering the Ansible Playbook code.
In this part, you will configure Ansible to run from a specific directory.
-
Ensure the
~/labs/lab15directory exists and navigate to this foldermkdir -p ~/labs/lab15 && cd ~/labs/lab15
-
Install the Ansible Python virtual environment named ansible
There are two main ways to set up a new Ansible workspace. Packages and Python virtual environments are both viable options. Here you choose to install Ansible in a Python virtual environment to take advantage of the latest release.
Start by installing the SSH development library package. This will enable the Python virtual environment to build the necessary tools.
sudo apt install -y libssh-dev
Next, create a
requirements.txtfile containing the list of required pip packages.cat << EOF > requirements.txt ansible ansible-lint ansible-pylibssh netaddr EOF
Finally, install the tools in a virtual environment called
ansible.touch .gitignore if ! grep -q ansible .gitignore; then echo ansible >>.gitignore fi python3 -m venv ansible source ./ansible/bin/activate pip3 install -r requirements.txt
-
Create a new
ansible.cfgfile in thelab15directory from the shell promptcat << 'EOF' > ansible.cfg # config file for Lab 15 Use Ansible to Back Up and Configure a c8000v Router [defaults] # Use inventory/ folder files as source inventory=inventory/ host_key_checking = False # Don't worry about RSA Fingerprints retry_files_enabled = False # Do not create them deprecation_warnings = False # Do not show warnings interpreter_python = /usr/bin/python3 [inventory] enable_plugins = auto, host_list, yaml, ini, toml, script [persistent_connection] command_timeout=100 connect_timeout=100 connect_retry_timeout=100 ssh_type = libssh EOF
-
Ensure that all necessary libraries and collections are up to date.
Update the Cisco Ansible collection to the latest version
ansible-galaxy collection install -f cisco.ios --upgrade
The local collections have priority over the system collection.
ansible-galaxy collection list cisco.ios
# /home/etu/.ansible/collections/ansible_collections Collection Version ---------- ------- cisco.ios 11.2.0 # /home/etu/labs/lab15/ansible/lib/python3.13/site-packages/ansible_collections Collection Version ---------- ------- cisco.ios 11.2.0 -
Create the
inventorydirectorymkdir ~/labs/lab15/inventoryThe contents of this directory will later be supplemented by the virtual router's connection parameters.
Start with a shell test connection before configuring Ansible.
Make sure the virtual router is already up and running. If this is not the case, refer to DevNet Lab 14 – Run the Cisco IOS XE router VM
:::warning Once again, be sure to change placeholders to match your resource allocation. :::
Here is a sample declaration file for programming hypervisor switch ports:
ovs:
switches:
- name: dsw-host
ports:
- name: tapXX7 # management interface connection
type: OVSPort
vlan_mode: access
tag: VVV # out-of-band VLAN id
- name: tapXX8 # in-band interface connection
type: OVSPort
vlan_mode: trunk
trunks: [300, 301, 302]
- name: tapXX9 # unused interface in this lab
type: OVSPort
vlan_mode: access
tag: 999
From the hypervisor shell, apply this switch configuration with the following command:
switch-conf.py --apply lab15-switch.yamlReturn to the DevNet development virtual machine shell, start an initial SSH connection to the virtual router, and accept the router's fingerprint.
A dedicated router entry was added to your SSH client configuration file during DevNet Lab 14. As a reminder, read this entry from the $HOME/.ssh/config file.
grep -A4 rtr $HOME/.ssh/configHost rtrXXX
HostName fe80::faad:caff:fefe:XXX%%enp0s1
User etu
Port 2222
ssh rtrXXXssh rtrXXX
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
(etu@fe80::faad:caff:fefe:7%enp0s1) Password:
rtrXXX#
:::success This SSH connection uses the default user account created by the Zero Touch Programming (ZTP) Python script. This initial router configuration only occurs when the router management interface is connected to the out-of-band auto-addressing VLAN. :::
-
Create a new vault file called
$HOME/.lab_passwd.yamland enter the unique vault password which will be used to store all user passwords.ansible-vault create $HOME/.lab_passwd.yamlNew Vault password: Confirm New Vault password:This opens the default editor which is defined by the
$EDITORenvironment variable. There, you will enter a variable name that designates the password for theansible_useruser account.ansible_user_passwd: 4n51bl3_53cr3t
-
Create the
ansible_useraccount on the IOS XE system of the C8000v virtual router.From the default user account SSH connection already opened, add a new user account with the highest privilege level and the same password as the one chosen for the ansible vault.
conf terminal user ansible_user privilege 15 secret 4n51bl3_53cr3t end copy running-config startup-config -
Close the default user SSH connection and open a new one with the identity ansible_user and list the IPv6 addresses of this router to fill the inventory file.
ssh ansible_user@rtrXXX
** WARNING: connection is not using a post-quantum key exchange algorithm. ** This session may be vulnerable to "store now, decrypt later" attacks. ** The server may need to be upgraded. See https://openssh.com/pq.html (ansible_user@fe80::faad:caff:fefe:XXX%enp0s1) Password: rtrXXX#show usersLine User Host(s) Idle Location *434 vty 0 ansible_us idle 00:00:00 FE80::BAAD:CAFF:FEFE:YYYY Interface User Mode Idle Peer Addressshow ipv6 int brief GigabitEthernet 1GigabitEthernet1 [up/up] FE80::FAAD:CAFF:FEFE:XXX 2001:678:3FC:34:FAAD:CAFF:FEFE:XXXIn the output above, you have a choice of 2 IPv6 addresses: the link-local address or the GUA address.
:::info It is good practice to have the DevNet VM and the out-of-band router interface on the same VLAN. This is why you chose to use the link-local IPv6 address in the inventory file. :::
Ansible uses an inventory file called hosts, which contains device information used by Ansible playbooks.
In this lab, you will be running Ansible from the Git project's lab directory. Therefore, you will need separate hosts and ansible.cfg files for each lab.
:::info The terms "hosts file" and "inventory file" are synonymous and are used interchangeably throughout the Ansible labs. :::
The Ansible inventory file defines the devices and groups of devices used by the Ansible playbook. The file can be in one of many formats, including YAML and INI, depending on your Ansible environment.
The inventory file can list devices by IP address or fully qualified domain name (FQDN), and can also include host-specific parameters.
Create the hosts.yaml inventory file in the inventory directory, add the following content to the file and save it. Any XXX tags must be edited to identify your own C8000v virtual router instance.
cat << EOF > inventory/hosts.yaml
ios:
hosts:
rtrXXX:
ansible_host: fe80::faad:caff:fefe:XXX%enp0s1
ansible_port: 2222
vars:
ansible_ssh_user: ansible_user
ansible_ssh_pass: "{{ ansible_user_passwd }}"
ansible_connection: network_cli
ansible_network_os: ios
all:
children:
ios:
EOFThe hosts.yaml file defines a group named 'ios' that contains a list of aliases for a set of devices. In this case, there is one alias named 'rtrXXX'. In an Ansible playbook, a host alias refers to a device specified by the ansible_host and ansible_port variables.
The hosts.yaml file also specifies variables that are shared by all hosts in the 'ios' group. Aliases in the 'vars' group are used to access the device. These are the SSH credentials that Ansible needs to securely access the c8000v virtual router.
ansible_ssh_user: : The username used to connect to the remote device. If this is not specified, the user running the Ansible playbook will be used instead.
ansible_ssh_pass:
: The password for the ansible_ssh_user. If omitted, the default SSH key will be used instead.
ansible_connection: : Specifies the library to use to connect to the device.
ansible_network_os: : Specifies the operating system used on the endpoint.
- Use the
ansible --versioncommand to display version information.
ansible --versionansible [core 2.20.2]
config file = /home/etu/labs/lab15/ansible.cfg
configured module search path = ['/home/etu/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /home/etu/labs/lab15/ansible/lib/python3.13/site-packages/ansible
ansible collection location = /home/etu/.ansible/collections:/usr/share/ansible/collections
executable location = /home/etu/labs/lab15/ansible/bin/ansible
python version = 3.13.12 (main, Feb 4 2026, 15:06:39) [GCC 15.2.0] (/home/etu/labs/lab15/ansible/bin/python3)
jinja version = 3.1.6
pyyaml version = 6.0.3 (with libyaml v0.2.5)
-
Parse your own ansible.cfg file.
Now, you need to edit your
ansible.cfgfile to look at the location of yourhosts.yamlinventory file.Open the
ansible.cfgfile and search lines referring to inventory.grep -A2 inventory ansible.cfg
# Use inventory/ folder files as source inventory=inventory/ host_key_checking = False # Don't worry about RSA Fingerprints retry_files_enabled = False # Do not create them -- [inventory] enable_plugins = auto, host_list, yaml, ini, toml, script [persistent_connection]As in Python, the
#symbol is used for comments within the ansible.cfg file.Comments cannot follow an entry. Ansible treats the
#symbol and the subsequent comment as part of the entry. Therefore, in these cases, the comment must be on a separate line.However, in some cases, variables can have a comment on the same line, as seen with host_key_checking and retry_files_enabled.
The
ansible.cfgfile tells Ansible where to find the inventory file and sets certain default parameters. Information you put in youransible.cfgfile includes:inventory=inventory/ : All your inventory files are in the inventory directory.
host_key_checking = False : There are no SSH keys set up in the local development environment. You have set host_key_checking to False, which is the default. In a production network, host_key_checking would be set to True.
retry_files_enabled = False : If Ansible has trouble running playbooks for a host, it will output the host name to a file in the current directory ending in retry. To avoid clutter, it is common to disable this setting.
In this part, you have configured Ansible to run in the lab directory.
In this lab, you will need an ansible.cfg file in your ansible directory and an inventory file hosts.yaml in the inventory directory.
- You edited the
hosts.yamlfile to contain login and IP address information for the virtual router - You edited the
ansible.cfgfile to use the local hosts file as the inventory file
In the next part, you will create a playbook to tell Ansible what to do.
The inventory status can be checked using the ansible-inventory command:
ansible-inventory --yaml --listall:
children:
ios:
hosts:
rtrXXX:
ansible_connection: network_cli
ansible_host: fe80::faad:caff:fefe:XXX%enp0s1
ansible_network_os: ios
ansible_port: 2222
ansible_ssh_pass: "{{ ansible_user_passwd }}"
ansible_ssh_user: ansible_user
The connection to the router device can be checked using the Ansible ping module:
ansible -m ping all --ask-vault-pass --extra-vars '@$HOME/.lab_passwd.yaml'Vault password:
rtrXXX | SUCCESS => {
"changed": false,
"ping": "pong"
}
In this part, you will create an Ansible playbook that automates the process of backing up the router configuration. Playbooks are central to Ansible. Whenever you want Ansible to retrieve information or perform an action on a device or group of devices, you run a playbook to get the job done.
An Ansible playbook is a YAML file containing one or more plays. Each play is a collection of tasks.
play : A matching set of tasks to a device or group of devices.
task : A single action that references a module to be executed along with any input arguments and actions. These tasks can be simple or complex, depending on the need for permissions, the order in which the tasks are executed, and so on.
A playbook may also contain roles. A role is a mechanism for splitting a playbook into multiple components or files, simplifying the playbook and making it easier to reuse. For example, the common role is used to store tasks that can be used in all of your playbooks. Roles are beyond the scope of this lab.
The Ansible YAML playbook includes objects, lists and modules.
- A YAML object consists of one or more key-value pairs. Key-value pairs are separated by a colon without quotation marks, for example hosts: Router.
- An object can contain other objects, such as a list. YAML uses lists or arrays. A hyphen "-" is used for each element in the list.
- Ansible ships with a set of modules (called the module library) that can be run directly on remote hosts or through playbooks. An example is the ios_command module, which is used to send commands to an IOS device and return the results. Each task typically consists of one or more Ansible modules.
The ansible-playbook command uses parameters to specify
- The vault file that contains the credentials to decrypt to connect to the user account specified in the inventory file.
- The playbook you want to run backup_router_playbook.yaml.
The Ansible playbook is a YAML file. Make sure you use the correct YAML indentation. Every space and hyphen are important. You may lose some formatting if you copy and paste the code in this lab.
-
Create a new file in the
ansibledirectory with the following name: backup_router_playbook.yaml -
Add the following information to the file.
# The purpose of this playbook is to automatically back up the running
# configuration of a Cisco router.
---
- name: AUTOMATIC BACKUP OF ROUTER CONFIGURATION
hosts: ios
tasks:
- name: BACKUP RUNNING CONFIG
cisco.ios.ios_config:
backup: true
...
The playbook you have created contains one play with one task. The following is an explanation of your playbook:
--- : This is at the beginning of every YAML file and tells YAML that this is a separate document. Each file can contain several documents, separated by ---.
name: : This is the name of the play.
hosts: ios
: This is the alias previously configured in the hosts.yaml file. By referencing this alias in your playbook, the playbook can use any parameters associated with this inventory file entry, including the IP address of the devices.
tasks: : This keyword specifies one or more tasks to perform. The task is to back up the router configuration.
cisco.ios.ios_config: : This is an Ansible module that is used to manage an IOS device configuration. The ios_config module belongs to the cisco.ios collection.
:::info
In the Linux terminal, you can use the **ansible-doc** *module_name* command to view the manual pages for any **module** and the parameters associated with that module. (e.g. **ansible-doc cisco.ios.ios_command or ansible-doc cisco.ios.ios_config**).
:::
backup:
: This parameter tells the ios_config module to create a backup of the device running configuration. The resulting configuration is saved locally as a backup file.
Now you can run the Ansible playbook using the ansible-playbook command:
ansible-playbook backup_router_playbook.yaml --ask-vault-pass --extra-vars '@$HOME/.lab_passwd.yaml'
Vault password: PLAY [AUTOMATIC BACKUP OF ROUTER CONFIGURATION] ***************************************
TASK [Gathering Facts] ****************************************************************
ok: [rtrXXX]
TASK [BACKUP RUNNING CONFIG] **********************************************************
changed: [rtrXXX]
PLAY RECAP ****************************************************************************
rtrXXX : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The PLAY RECAP should display ok=2 changed=1 indicating a successful playbook execution.
If your Ansible playbook fails, check the following items:
- Ensure that your
hosts.yamlandansible.cfgfiles are correct. - Ensure the YAML indentation is correct.
- Ensure that your IOS command is correct.
- Check the syntax of the entire Ansible playbook.
- Verify that you can ping the router.
If you continue to have problems, check the inventory file content with the ansible-inventory command:
ansible-inventory --yaml --listall:
children:
ios:
hosts:
rtrXXX:
ansible_connection: network_cli
ansible_host: fe80::faad:caff:fefe:XXX%enp0s1
ansible_network_os: ios
ansible_port: 2222
ansible_ssh_pass: "{{ ansible_user_passwd }}"
ansible_ssh_user: ansible_user
- Check your playbook syntax with the ansible-lint command:
ansible-lint backup_router_playbook.yamlPassed: 0 failure(s), 0 warning(s) on 1 files. Last profile that met the validation criteria was 'production'.
The command results will show you the lines where there are syntax errors.
List the files in the backup folder and open the most recent one. You can also use the head command to print the first few lines of the backup file. You now have a backup of the router configuration.
ls -A backuprtrXXX_config.2025-06-11@15:01:26
head -n 20 backup/rtrXXX_config.2025-06-11@15\:01\:26 Building configuration...
Current configuration : 7682 bytes
!
! Last configuration change at 08:58:24 WEST Sun Feb 15 2026 by etu
! NVRAM config last updated at 08:58:27 WEST Sun Feb 15 2026 by etu
!
version 17.18
service timestamps debug datetime msec
service timestamps log datetime localtime show-timezone
platform qfp utilization monitor load 80
platform sslvpn use-pd
platform console serial
!
hostname rtrXXX
!
boot-start-marker
boot-end-marker
!
In this part, you will create another Ansible playbook to configure IPv6 addressing on the C8000v router.
Create a subdirectory named host_vars, which will store variables of any device named in the inventory
mkdir host_varsmkdir: created directory 'host_vars'
Create a new YAML file in the host_vars directory with the device name: rtrXXX.yaml
---
rtr_id: XXX
# The IPv6 addresses are calculated by concatenating the following elements:
# the global prefix, the VLAN ID, the router ID as the interface ID, and the mask.
# For instance, the IPv6 address for VLAN 100 on router 7 is 2001:678:3fc:64::XXX/64.
ipv6_prefix: "2001:678:3fc:"
ipv6_intf_id: "{{ '%x' % rtr_id }}"
ipv6_mask: 64
# The main parent interface is GigabitEthernet2.
# The subinterfaces are created by appending the VLAN ID to the main interface.
# For instance, the subinterface for VLAN 100 is GigabitEthernet2.100.
interfaces:
- type: GigabitEthernet
id: 2
vlans:
- name: red
id: 300
desc: RED VLAN SUBINTERFACE
- name: purple
id: 301
desc: PURPLE VLAN SUBINTERFACE
- name: blue
id: 302
desc: BLUE VLAN SUBINTERFACE
Create a new file named router_config_playbook.yaml in your lab directory, and add the following tasks to the file. Make sure you use the proper YAML indentation. Every space and dash is significant.
# The purpose of this playbook is to configure IPv6 addresses on subinterfaces
# of a Cisco router.
#
# 1. Calculate IPv6 addresses for each VLAN
# 2. Display the calculated IPv6 addresses, networks, and gateways
# 3. Configure the main parent interface
# 4. Configure subinterfaces with dot1Q encapsulation, IPv6 addresses, and additional settings
# 5. Save the output of the IPv6 interface brief
# 6. Test reachability to the gateway address of each VLAN
# 7. Save the results of the reachability test
---
- name: VLAN SUBINTERFACES CONFIGURATION
hosts: all
vars:
# Use the first interface from the interfaces list
interface: "{{ interfaces[0] }}"
tasks:
- name: CALCULATE IPv6 ADDRESSES
ansible.builtin.set_fact:
# noqa: jinja[invalid]
calculated_vlans: >
{{
calculated_vlans | default([]) +
[
item | combine(
{
'addr': ipv6_prefix ~
('%x' % (item.id | int)) ~
'::' ~
ipv6_intf_id ~
'/' ~
ipv6_mask
}
)
]
}}
loop: "{{ interface.vlans }}"
loop_control:
label: "{{ item.name }}"
- name: VARS ANALYSIS
ansible.builtin.debug:
msg:
- "vlan: {{ item.id }} --> address: {{ item.addr }}"
- "Network: {{ item.addr | ansible.utils.ipaddr('network') }}/{{ item.addr | ansible.utils.ipaddr('prefix') }}"
- "Gateway: {{ item.addr | ansible.utils.ipaddr('1') }}"
loop: "{{ calculated_vlans }}"
- name: MAIN PARENT INTERFACE CONFIG
cisco.ios.ios_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}"
description: IPV6 ANSIBLE PLAYBOOK CONFIGURATION
enabled: true
- name: SUB INTERFACES CONFIG
cisco.ios.ios_config:
lines:
- description {{ item.desc }}
- encapsulation dot1Q {{ item.id }}
- ipv6 address {{ item.addr }}
- ipv6 enable
- ipv6 nd ra suppress all
parents:
- interface {{ interface.type }}{{ interface.id }}.{{ item.id }}
match: exact
replace: block
with_items: "{{ calculated_vlans }}"
- name: SHOW IPv6 INTERFACE BRIEF
cisco.ios.ios_command:
commands:
- show ipv6 interface brief
register: output
- name: ENSURE TRACE DIRECTORY EXISTS
delegate_to: localhost
ansible.builtin.file:
path: trace
state: directory
mode: "0755"
- name: SAVE OUTPUT
delegate_to: localhost
ansible.builtin.copy:
content: "{{ output.stdout[0] }}"
dest: "trace/ipv6_int_brief_{{ inventory_hostname }}.txt"
mode: "0644"
- name: REACHABILITY TEST
cisco.ios.ios_ping:
dest: "{{ item.addr | ansible.utils.ipaddr('1') | ansible.utils.ipaddr('address') }}"
afi: ipv6
loop: "{{ calculated_vlans }}"
register: ping_results
- name: SAVE PING RESULTS
delegate_to: localhost
ansible.builtin.copy:
content: "{{ ping_results.results | to_nice_json }}"
dest: "trace/ipv6_ping_{{ inventory_hostname }}.txt"
mode: "0644"
The main purpose of this playbook is to illustrate variable formatting and calculations. A set of VLAN identifiers is defined based on the content of the host_vars/rtrXXX.yaml file. Then IPv6 subinterface addresses are calculated using a combination of:
- The prefix represents the network portion
- The VLAN ID stands for the subnetwork part
- The router id stands for the host part
Note that you only need to change the router and VLAN identifiers to have all the IPv6 addresses automatically calculated.
Now, let's review the functionality of the router_config_playbook.yaml file. Below is a brief description of the elements used:
CALCULATE IPv6 ADDRESSES: : This iteratively builds a list of enriched VLAN objects by adding a calculated IPv6 address to each VLAN entry. This is done using the hexadecimal VLAN ID in the network portion and the router ID in the host portion. The result follows the standard IPv6 address format with a prefix and mask.
VARS ANALYSIS: : Use the debug module to view the calculation results as a detailed network address plan.
MAIN PARENT INTERFACE CONFIG: : Use the ios_interfaces module to enable the parent network interface and set its description.
SUB INTERFACES CONFIG:
: Use the ios_config module to send line-by-line configuration instructions for each VLAN subinterface. This method is useful for configuring network interfaces when Ansible libraries do not provide commands. However, idempotency is lost, and a warning is sent each time the playbook runs.
Use the ansible-doc cisco.ios.ios_config command to view the details for the parents and match parameters used in this playbook.
SHOW IPv6 INTERFACE BRIEF: : Use the ios_command module to send the show ipv6 interface brief command. The output of the command is registered in an Ansible variable called output.
ENSURE TRACE DIRECTORY EXISTS:
: Creates the trace directory in the DevNet lab tree if necessary.
SAVE OUTPUT:
: Use the copy module to save the contents of the output variable to a file located in the trace directory on the DevNet VM.
REACHABILITY TEST: : Use the ios_ping module to send ICMPv6 requests to the network gateway of each subinterface VLAN.
Now you can run the Ansible playbook with the ansible-playbook command. The -vvv verbose option can be added to display the tasks being performed in the playbook.
:::warning
Do not forget to replace the rtr_id value in the host_vars/rtrXXX.yaml with your own out-of-band tap interface number!. This is mandatory in order to avoid duplicate addresses on different virtual routers.
:::
ansible-playbook router_config_playbook.yaml --ask-vault-pass --extra-vars '@$HOME/.lab_passwd.yaml'
Vault password:PLAY [VLAN SUBINTERFACES CONFIGURATION] *************************************
TASK [Gathering Facts] ******************************************************
ok: [rtrXXX]
TASK [CALCULATE IPv6 ADDRESSES] *********************************************
ok: [rtrXXX] => (item=red)
ok: [rtrXXX] => (item=purple)
ok: [rtrXXX] => (item=blue)
TASK [VARS ANALYSIS] ********************************************************
ok: [rtrXXX] => (item={'name': 'red', 'id': 300, 'desc': 'RED VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12c::XXX/64'}) => {
"msg": [
"vlan: 300 --> address: 2001:678:3fc:12c::XXX/64",
"Network: 2001:678:3fc:12c::/64",
"Gateway: 2001:678:3fc:12c::1/64"
]
}
ok: [rtrXXX] => (item={'name': 'purple', 'id': 301, 'desc': 'PURPLE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12d::XXX/64'}) => {
"msg": [
"vlan: 301 --> address: 2001:678:3fc:12d::XXX/64",
"Network: 2001:678:3fc:12d::/64",
"Gateway: 2001:678:3fc:12d::1/64"
]
}
ok: [rtrXXX] => (item={'name': 'blue', 'id': 302, 'desc': 'BLUE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12e::XXX/64'}) => {
"msg": [
"vlan: 302 --> address: 2001:678:3fc:12e::XXX/64",
"Network: 2001:678:3fc:12e::/64",
"Gateway: 2001:678:3fc:12e::1/64"
]
}
TASK [MAIN PARENT INTERFACE CONFIG] *****************************************
ok: [rtrXXX]
TASK [SUB INTERFACES CONFIG] ************************************************
[WARNING]: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device
changed: [rtrXXX] => (item={'name': 'red', 'id': 300, 'desc': 'RED VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12c::XXX/64'})
changed: [rtrXXX] => (item={'name': 'purple', 'id': 301, 'desc': 'PURPLE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12d::XXX/64'})
changed: [rtrXXX] => (item={'name': 'blue', 'id': 302, 'desc': 'BLUE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12e::XXX/64'})
TASK [SHOW IPv6 INTERFACE BRIEF] ********************************************
ok: [rtrXXX]
TASK [ENSURE TRACE DIRECTORY EXISTS] ****************************************
ok: [rtrXXX -> localhost]
TASK [SAVE OUTPUT] **********************************************************
ok: [rtrXXX -> localhost]
TASK [REACHABILITY TEST] ****************************************************
ok: [rtrXXX] => (item={'name': 'red', 'id': 300, 'desc': 'RED VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12c::XXX/64'})
ok: [rtrXXX] => (item={'name': 'purple', 'id': 301, 'desc': 'PURPLE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12d::XXX/64'})
ok: [rtrXXX] => (item={'name': 'blue', 'id': 302, 'desc': 'BLUE VLAN SUBINTERFACE', 'addr': '2001:678:3fc:12e::XXX/64'})
TASK [SAVE PING RESULTS] ****************************************************
changed: [rtrXXX -> localhost]
PLAY RECAP ******************************************************************
rtrXXX : ok=10 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
All of these playbook tasks have been successfully completed. The ok counter has reached 10, which means that all 10 tasks have been completed.
The changed counter is 2, meaning 2 changes have been made. If you look for the changed keyword in the playbook run screenshot above, you can identify these configuration updates.
- IPv6 addresses have been set for the three VLANs declared in the router's
host_varsfile. - Ping results have been saved as artifacts that prove network communications are functional.
You can view the contents of the trace file with cat trace/ipv6_int_brief_rtrXXX.txt. You now have a trace of the virtual router's interface and subinterface configuration.
cat trace/ipv6_int_brief_rtrXXX.txt GigabitEthernet1 [up/up]
FE80::FAAD:CAFF:FEFE:XXX
2001:678:3FC:34:FAAD:CAFF:FEFE:XXX
GigabitEthernet2 [up/up]
unassigned
GigabitEthernet2.300 [up/up]
FE80::FAAD:CAFF:FEFE:YYY
2001:678:3FC:12C::XXX
GigabitEthernet2.301 [up/up]
FE80::FAAD:CAFF:FEFE:YYY
2001:678:3FC:12D::XXX
GigabitEthernet2.302 [up/up]
FE80::FAAD:CAFF:FEFE:YYY
2001:678:3FC:12E::XXX
GigabitEthernet3 [administratively down/down]
unassigned
Before refactoring the playbook, running it multiple times always kept the changed counter at 2, even though the VLAN subinterfaces were already configured.
And here comes the devil!
Applying the same addresses each time the playbook runs can cause communications to break.
[WARNING]: To ensure idempotency and correct diff the input configuration lines
should be similar to how they appear if present in the running configuration on device
When you dig deeper into the IOS configuration lines, no matter how hard you try, you always get the warning. So, the idempotency claim is broken.
In our router_config_playbook.yaml playbook, you chose to use the cisco.ios.ios_config, which sends lines of IOS XE instructions in configuration mode. In addition, the replace: block statement should ensure an idempotent configuration by replacing the entire specified configuration block for each subinterface rather than attempting to merge individual lines. This should ensure that the interface configuration matches exactly what's defined in the playbook.
- name: SUB INTERFACES CONFIG
cisco.ios.ios_config:
lines:
- description {{ item.desc }}
- encapsulation dot1Q {{ item.id }}
- ipv6 address {{ item.addr }}
- ipv6 enable
- ipv6 nd ra suppress all
parents:
- interface {{ interface.type }}{{ interface.id }}.{{ item.id }}
match: exact
replace: block
with_items: "{{ calculated_vlans }}"
The problem we're encountering is symptomatic of using the syntax of a network device configuration language like IOS, which was not designed for automation in the first place.
There are two possible solutions to overcome this:
- Abandon the configuration language and use only the APIs
- Break the task into multiple subtasks using elementary modules to ensure idempotency at an almost atomic level.
Since using APIs for our virtual router configuration is beyond the scope of this lab, you choose the latter.
Here is a new block of 3 tasks, replacing the previous single SUB INTERFACES CONFIG task, each using a specific module to achieve idempotent processing.
- name: STEP 3 - CONFIGURE MAIN PARENT INTERFACE
cisco.ios.ios_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}"
description: IPV6 ANSIBLE PLAYBOOK CONFIGURATION
enabled: true
state: merged
register: parent_config_result
# STEP 4: CONFIGURE SUBINTERFACES
# Demonstrates: Consolidated idempotent configuration (state: merged)
- name: STEP 4 - CONFIGURE SUBINTERFACES WITH IPv6
block:
# Create subinterfaces with description
- name: STEP 4A - CREATE SUBINTERFACES
cisco.ios.ios_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}.{{ item.id }}"
description: "{{ item.desc }}"
enabled: true
state: merged
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
# Configure encapsulation and IPv6 settings (L2/L3)
- name: STEP 4B - CONFIGURE ENCAPSULATION AND L3/ND SETTINGS
cisco.ios.ios_config:
lines:
- encapsulation dot1Q {{ item.id }}
- ipv6 enable
- ipv6 nd ra suppress all
parents: "interface {{ interface.type }}{{ interface.id }}.{{ item.id }}"
match: line
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
# Assign IPv6 addresses (idempotent)
- name: STEP 4C - ASSIGN IPv6 ADDRESSES
cisco.ios.ios_l3_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}.{{ item.id }}"
ipv6:
- address: "{{ item.addr | ansible.utils.ipaddr('address') }}/{{ item.addr | ansible.utils.ipaddr('prefix') }}"
state: merged
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
register: l3_config_result
rescue:
- name: ERROR - LOG CONFIGURATION FAILURE
ansible.builtin.debug:
msg: |
⚠️ Configuration failed on subinterfaces. Possible causes:
• Device connectivity issue
• Invalid interface names
• IPv6 format error
• Device resource limitations
- name: ERROR - FAIL PLAYBOOK
ansible.builtin.fail:
msg: Subinterface configuration failed. Review previous messages and device connectivity.
# STEP 5: VERIFY CONFIGURATION
# Demonstrates: Validation that actual state matches expected state (compliance)
- name: STEP 5 - VERIFY CONFIGURATION COMPLIANCE
block:
- name: STEP 5A - RETRIEVE IPv6 INTERFACE CONFIGURATION
cisco.ios.ios_command:
commands:
- show ipv6 interface brief
register: ipv6_status
until: ipv6_status.stdout_lines[0] | length > 0
retries: 3
delay: 2
- name: STEP 5B - VALIDATE SUBINTERFACES ARE CONFIGURED
ansible.builtin.assert:
that:
- calculated_vlans | length > 0
- ipv6_status.stdout[0] is search(interface.type)
fail_msg: Subinterfaces not properly configured
success_msg: ✓ All subinterfaces are configured
register: validation_result
rescue:
- name: WARNING - VERIFICATION FAILED
ansible.builtin.debug:
msg: Configuration verification incomplete. Check device connectivity.
failed_when: false
cisco.ios.ios_interfaces : Manages the interface configuration on Cisco IOS devices. Define, modify, or remove physical and logical interfaces with attributes such as descriptions, MTU settings, and administrative state (enabled/disabled).
cisco.ios.ios_l3_interfaces : Manages Layer 3 interface configurations on Cisco IOS devices, allowing for the declarative configuration of IPv4 and IPv6 addresses with appropriate subnet masks or prefixes using state-based operations (merged, replaced, overridden, or deleted).
cisco.ios.ios_config : Pushes configuration commands to Cisco IOS devices, supporting parent-child relationships for hierarchical commands, various matching strategies (line, strict, exact), and configuration replacement methods (line, block) to manage device configurations.
As you can see, the cisco.ios.ios_config module is a last resort solution when no other module in the collection provides the required configuration options.
Why use a task block? Using a block in this playbook allows for logical grouping of related tasks for subinterface configuration, provides structured error handling with the rescue clause, and ensures that if any step in the configuration process fails, the playbook can gracefully report the error and take appropriate recovery actions without leaving interfaces in an inconsistent state.
Start by replacing the original SUB INTERFACES CONFIG task with the suggested block of three subtasks. Then run the playbook again and look at the changed counter value.
Here is an excerpt of the playbook run focusing on the three subinterfaces configuration processing.
PLAY [VLAN SUBINTERFACES CONFIGURATION] *************************************
TASK [Gathering Facts] ******************************************************
ok: [rtrXXX]
TASK [STEP 1 - CALCULATE IPv6 ADDRESSES FOR VLANS] **************************
ok: [rtrXXX] => (item=red)
ok: [rtrXXX] => (item=purple)
ok: [rtrXXX] => (item=blue)
TASK [STEP 2 - DISPLAY CALCULATED IPv6 ADDRESSES] ***************************
ok: [rtrXXX] => (item=red) => {
"msg": [
"VLAN: 300 | Name: red",
" → Address: 2001:678:3fc:12c::XXX/64",
" → Network: 2001:678:3fc:12c::/64",
" → Gateway: 2001:678:3fc:12c::1/64"
]
}
ok: [rtrXXX] => (item=purple) => {
"msg": [
"VLAN: 301 | Name: purple",
" → Address: 2001:678:3fc:12d::XXX/64",
" → Network: 2001:678:3fc:12d::/64",
" → Gateway: 2001:678:3fc:12d::1/64"
]
}
ok: [rtrXXX] => (item=blue) => {
"msg": [
"VLAN: 302 | Name: blue",
" → Address: 2001:678:3fc:12e::XXX/64",
" → Network: 2001:678:3fc:12e::/64",
" → Gateway: 2001:678:3fc:12e::1/64"
]
}
TASK [STEP 3 - CONFIGURE MAIN PARENT INTERFACE] *****************************
ok: [rtrXXX]
TASK [STEP 4A - CREATE SUBINTERFACES] **************************************
ok: [rtrXXX] => (item=red)
ok: [rtrXXX] => (item=purple)
ok: [rtrXXX] => (item=blue)
TASK [STEP 4B - CONFIGURE ENCAPSULATION AND L3/ND SETTINGS] *****************
ok: [rtrXXX] => (item=red)
ok: [rtrXXX] => (item=purple)
ok: [rtrXXX] => (item=blue)
TASK [STEP 4C - ASSIGN IPv6 ADDRESSES] **************************************
ok: [rtrXXX] => (item=red)
ok: [rtrXXX] => (item=purple)
ok: [rtrXXX] => (item=blue)
TASK [STEP 5A - RETRIEVE IPv6 INTERFACE CONFIGURATION] **********************
ok: [rtrXXX]
TASK [STEP 5B - VALIDATE SUBINTERFACES ARE CONFIGURED] *********************
ok: [rtrXXX] => {
"changed": false,
"msg": "✓ All subinterfaces are configured"
}
TASK [STEP 6A - CREATE TRACE DIRECTORY] *************************************
ok: [rtrXXX -> localhost]
TASK [STEP 6B - SAVE IPv6 INTERFACE BRIEF] **********************************
ok: [rtrXXX -> localhost]
TASK [STEP 7 - TEST GATEWAY REACHABILITY] ***********************************
ok: [rtrXXX] => (item=red → 2001:678:3fc:12c::1)
ok: [rtrXXX] => (item=purple → 2001:678:3fc:12d::1)
ok: [rtrXXX] => (item=blue → 2001:678:3fc:12e::1)
TASK [STEP 8 - SAVE CONNECTIVITY TEST RESULTS] ******************************
changed: [rtrXXX -> localhost]
PLAY RECAP ******************************************************************
rtrXXX : ok=13 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
You win! There is no warning message or change indicator when network interfaces have their IPv6 addresses already configured.
If you look at the playbook recap, the changed counter value is 1 because you saved the ping results as new artifacts.
PLAY RECAP ***********************************************************************
rtrXXX : ok=12 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Before refactoring the playbook, running it multiple times always kept the changed counter at 2, even though the VLAN subinterfaces were already configured.
You can now conclude this part by acknowledging that idempotency in Ansible playbooks comes at a cost. When designing tasks, you need to think in terms of fine-grained tuning.
This lab provides a practical introduction to network automation using Ansible, with a focus on both functionality and idempotency. Through hands-on exercises, you configured Ansible, backed up router configurations, and automated IPv6 addressing on router interfaces.
By creating and refining playbooks, you experienced the process of developing efficient and idempotent automation scripts. The lab highlighted the challenges of achieving true idempotency when working with network device configuration languages, and demonstrated how to overcome these challenges by breaking down tasks and using specialized Ansible modules.
You learned that while Ansible claims to provide idempotent operations, achieving this in practice often requires careful consideration and sometimes refactoring of playbook tasks. The lab showed how to analyze playbook execution, identify non-idempotent behavior, and implement solutions to ensure that repeated playbook runs produce consistent and predictable results.
This experience serves as a foundation for more advanced network automation projects, emphasizing the importance of not just automating tasks, but doing so in a way that is reliable, repeatable, and efficient. The skills acquired in this lab will be valuable as you continue to explore and implement network automation strategies in more complex environments.
Here is a copy of the final Ansible Playbook based on the idempotency design criteria.
# VLAN SUBINTERFACES IPv6 CONFIGURATION PLAYBOOK
# ================================================
# This playbook demonstrates idempotent configuration management:
# - state: merged ensures tasks only modify device config if needed
# - Repeated runs produce the same result (idempotence principle)
#
# Educational objectives:
# 1. Calculate IPv6 addresses dynamically for each VLAN
# 2. Display calculated addresses network/gateway
# 3. Configure main parent interface (idempotent)
# 4. Configure subinterfaces with encapsulation and IPv6 (idempotent)
# 5. Verify configuration matches expected state
# 6. Test connectivity to each VLAN gateway
# 7. Document results
---
- name: VLAN SUBINTERFACES CONFIGURATION
hosts: all
vars:
# Use the first interface from the interfaces list
interface: "{{ interfaces[0] }}"
tasks:
# STEP 1: CALCULATE IPv6 ADDRESSES
# Demonstrates: Dynamic variable creation with Jinja2 filters
- name: STEP 1 - CALCULATE IPv6 ADDRESSES FOR VLANS
ansible.builtin.set_fact:
# noqa: jinja[invalid]
calculated_vlans: >
{{
calculated_vlans | default([]) +
[
item | combine(
{
'addr': ipv6_prefix ~
('%x' % (item.id | int)) ~
'::' ~
ipv6_intf_id ~
'/' ~
ipv6_mask
}
)
]
}}
loop: "{{ interface.vlans }}"
loop_control:
label: "{{ item.name }}"
# STEP 2: DISPLAY CALCULATED VALUES
# Demonstrates: Debug output for verification before applying changes
- name: STEP 2 - DISPLAY CALCULATED IPv6 ADDRESSES
ansible.builtin.debug:
msg:
- "VLAN: {{ item.id }} | Name: {{ item.name }}"
- " → Address: {{ item.addr }}"
- " → Network: {{ item.addr | ansible.utils.ipaddr('network') }}/{{ item.addr | ansible.utils.ipaddr('prefix') }}"
- " → Gateway: {{ item.addr | ansible.utils.ipaddr('1') }}"
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
# STEP 3: CONFIGURE MAIN PARENT INTERFACE
# Demonstrates: state: merged (idempotent - applies only if needed)
- name: STEP 3 - CONFIGURE MAIN PARENT INTERFACE
cisco.ios.ios_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}"
description: IPV6 ANSIBLE PLAYBOOK CONFIGURATION
enabled: true
state: merged
register: parent_config_result
# STEP 4: CONFIGURE SUBINTERFACES
# Demonstrates: Consolidated idempotent configuration (state: merged)
- name: STEP 4 - CONFIGURE SUBINTERFACES WITH IPv6
block:
# Create subinterfaces with description
- name: STEP 4A - CREATE SUBINTERFACES
cisco.ios.ios_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}.{{ item.id }}"
description: "{{ item.desc }}"
enabled: true
state: merged
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
# Configure encapsulation and IPv6 settings (L2/L3)
- name: STEP 4B - CONFIGURE ENCAPSULATION AND L3/ND SETTINGS
cisco.ios.ios_config:
lines:
- encapsulation dot1Q {{ item.id }}
- ipv6 enable
- ipv6 nd ra suppress all
parents: "interface {{ interface.type }}{{ interface.id }}.{{ item.id }}"
match: line
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
# Assign IPv6 addresses (idempotent)
- name: STEP 4C - ASSIGN IPv6 ADDRESSES
cisco.ios.ios_l3_interfaces:
config:
- name: "{{ interface.type }}{{ interface.id }}.{{ item.id }}"
ipv6:
- address: "{{ item.addr | ansible.utils.ipaddr('address') }}/{{ item.addr | ansible.utils.ipaddr('prefix') }}"
state: merged
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }}"
register: l3_config_result
rescue:
- name: ERROR - LOG CONFIGURATION FAILURE
ansible.builtin.debug:
msg: |
⚠️ Configuration failed on subinterfaces. Possible causes:
• Device connectivity issue
• Invalid interface names
• IPv6 format error
• Device resource limitations
- name: ERROR - FAIL PLAYBOOK
ansible.builtin.fail:
msg: Subinterface configuration failed. Review previous messages and device connectivity.
# STEP 5: VERIFY CONFIGURATION
# Demonstrates: Validation that actual state matches expected state (compliance)
- name: STEP 5 - VERIFY CONFIGURATION COMPLIANCE
block:
- name: STEP 5A - RETRIEVE IPv6 INTERFACE CONFIGURATION
cisco.ios.ios_command:
commands:
- show ipv6 interface brief
register: ipv6_status
until: ipv6_status.stdout_lines[0] | length > 0
retries: 3
delay: 2
- name: STEP 5B - VALIDATE SUBINTERFACES ARE CONFIGURED
ansible.builtin.assert:
that:
- calculated_vlans | length > 0
- ipv6_status.stdout[0] is search(interface.type)
fail_msg: Subinterfaces not properly configured
success_msg: ✓ All subinterfaces are configured
register: validation_result
rescue:
- name: WARNING - VERIFICATION FAILED
ansible.builtin.debug:
msg: Configuration verification incomplete. Check device connectivity.
failed_when: false
# STEP 6: SAVE CONFIGURATION OUTPUT
# Demonstrates: Documentation of applied configuration
- name: STEP 6 - SAVE CONFIGURATION OUTPUTS
block:
- name: STEP 6A - CREATE TRACE DIRECTORY
delegate_to: localhost
ansible.builtin.file:
path: trace
state: directory
mode: "0755"
- name: STEP 6B - SAVE IPv6 INTERFACE BRIEF
delegate_to: localhost
ansible.builtin.copy:
content: "{{ ipv6_status.stdout[0] }}"
dest: trace/ipv6_int_brief_{{ inventory_hostname }}.txt
mode: "0644"
rescue:
- name: WARNING - SAVE CONFIGURATION FAILED
ansible.builtin.debug:
msg: Failed to save configuration outputs. Check trace directory permissions.
failed_when: false
# STEP 7: TEST CONNECTIVITY
# Demonstrates: Functional validation (interfaces respond to IPv6)
- name: STEP 7 - TEST GATEWAY REACHABILITY
cisco.ios.ios_ping:
dest: "{{ item.addr | ansible.utils.ipaddr('1') | ansible.utils.ipaddr('address') }}"
afi: ipv6
count: 2
loop: "{{ calculated_vlans }}"
loop_control:
label: "{{ item.name }} → {{ item.addr | ansible.utils.ipaddr('1') | ansible.utils.ipaddr('address') }}"
register: ping_results
ignore_errors: true
# STEP 8: SAVE VALIDATION RESULTS
- name: STEP 8 - SAVE CONNECTIVITY TEST RESULTS
delegate_to: localhost
ansible.builtin.copy:
content: "{{ ping_results.results | to_nice_json }}"
dest: "trace/ipv6_ping_{{ inventory_hostname }}.txt"
mode: "0644"
