173 views
# DevNet Lab 17 -- Automated testing with pyATS and Genie [toc] --- ### Scenario In this lab, you will explore the basics of [**pyATS**](https://developer.cisco.com/pyats/) (pronounced "py" followed by each letter individually, "A" "T" "S") and Genie. The pyATS tool is an end-to-end testing ecosystem that specializes in data-driven and reusable testing, and is designed for agile, rapid development iterations. Genie extends and builds upon pyATS for use in a networked environment. Examples of features provided by Genie include: - Device connectivity, parsers, and APIs - Cisco platform-agnostic Python object models for features like OSPF and BGP - Pool of reusable test cases - YAML-based test-runner engine ![pyATS diagram](https://pubhub.devnetcloud.com/media/pyats-getting-started/docs/_images/layers.png) The true benefit of pyATS is the ability to quickly and easily test the **configuration state** and **operational state** of a device for compliance with the **desired state** of the network. While there are benefits to both types of testing, this lab is designed to familiarize you with operational state testing to provide a more complete view of your network's current status. ![Lab17 topology](https://md.inetdoc.net/uploads/15c91fe7-8ea3-4303-8875-f13c14ce9268.png) To illustrate **operational state** testing with pyats, we need to run two routers that are OSPF protocol neighbors exchanging networks. This will allow us to test for the presence of an IPv4 network prefix on the spoke router that is advertised by the hub router. ### Objectives After completing these lab activities, you will be able to: - **Set Up a Lab Environment**: Configure a lab environment with two virtual routers to test OSPFv3 configurations using pyATS and Genie. - **Implement OSPFv3 Configurations**: Apply initial OSPFv3 configurations on both hub and spoke routers to establish network connectivity, and manually verify that OSPFv3 is working properly by examining routing tables and neighbor lists. - **Develop custom Python tests**: Create Python scripts using pyATS to automate testing of network configurations and operational status, focusing on developing reusable test cases. - **Execute automated tests**: Use pyATS to run automated tests to ensure that network configurations match expected states and that OSPFv3 is operating properly. ## Part 1: Prepare the lab environment In this part, you will set up the lab environment. From the Devnet VM, we need to manage two routers that exchange IPv4 and IPv6 networks. Therefore, we will start by declaring and configuring the hypervisor resources: six switch ports and two virtual routers. Then we set the initial configuration for each OSPFv3 routing daemon. Here is a connection scheme table for both routers: | Router interface | tap interface mode | VLAN(s) | |:------ |:------- |:---- | | GigabitEthernet1 | access mode | out-of-band | | GigabitEthernet2 | access mode | isolated VLAN | | GigabitEthernet3 | access mode | unused | ### Step 1: Declare and configure hypervisor switch ports We use the [**switch-conf.py**](https://gitlab.inetdoc.net/labs/startup-scripts/-/blob/main/switch-conf.py?ref_type=heads) script with a YAML declaration file to configure the three switch ports needed by the virtual router. Here is a template YAML declaration file named `switch.yaml` that configures 3 tap interfaces per router as described in the topology diagram: ```yaml= ovs: switches: - name: dsw-host ports: - name: tapU # <-- SPOKE ROUTER G1 MGMT type: OVSPort vlan_mode: access tag: _OOB_VLAN_ID_ # <-- YOUR OUT-OF-BAND VLAN ID - name: tapV # <-- SPOKE ROUTER G2 type: OVSPort vlan_mode: access tag: _VVV_ # <-- YOUR PRIVATE VLAN ID FOR OSPF - name: tapW # <-- SPOKE ROUTER G3 type: OVSPort vlan_mode: access tag: 999 # <-- UNUSED VLAN ID FOR THIS LAB - name: tapX # <-- HUB ROUTER G1 MGMT type: OVSPort vlan_mode: access tag: _OOB_VLAN_ID_ # <-- YOUR OUT-OF-BAND VLAN ID - name: tapY # <-- HUB ROUTER G2 type: OVSPort vlan_mode: access tag: _VVV_ # <-- YOUR PRIVATE VLAN ID FOR OSPF - name: tapZ # <-- HUB ROUTER G3 type: OVSPort vlan_mode: access tag: 999 # <-- UNUSED VLAN ID FOR THIS LAB ``` :::warning As with all the labs in this series, be sure to replace all VLAN ID placeholders with your own identifiers. ::: Here is a sample trace of the `switch-conf.py` script execution with some tap interface configuration changes: ```bash switch-conf.py switch.yaml ``` ```bash= ---------------------------------------- Switch dsw-host exists >> Port tapU vlan_mode is already set to access >> Port tapU tag is already set to _OOB_VLAN_ID_ >> Port tapV vlan_mode changed to access >> Port tapV tag changed to _VVV_ >> Port tapW vlan_mode is already set to access >> Port tapW tag is already set to 999 >> Port tapX vlan_mode is already set to access >> Port tapX tag is already set to _OOB_VLAN_ID_ >> Port tapY vlan_mode changed to access >> Port tapY tag changed to _VVV_ >> Port tapZ vlan_mode is already set to access >> Port tapZ tag is already set to 999 ---------------------------------------- ``` ### Step 2: Declare and start the two hub-and-spoke routers As with switch ports, we start by creating a YAML declaration file for our virtual router. Here is an example declaration file named `lab17-routers.yaml`. ```yaml= kvm: vms: - vm_name: lab17-spoke os: iosxe master_image: c8000v-universalk9.17.16.01a.qcow2 force_copy: false tapnumlist: [U, V, W] # <-- YOUR TAP INTERFACE NUMBERS - vm_name: lab17-hub os: iosxe master_image: c8000v-universalk9.17.16.01a.qcow2 force_copy: false tapnumlist: [X, Y, Z] # <-- YOUR TAP INTERFACE NUMBERS ``` :::warning Again, be sure to replace the placeholders in any `tapnumlist` list of tap interface numbers. ::: Here is an example of running the `lab-startup.py` script: ```bash lab-startup.py lab17-routers.yaml ``` ```bash= Copying /home/etudianttest/masters/c8000v-universalk9.17.16.01a.qcow2 to lab17-spoke.qcow2... done. Creating OVMF_CODE.fd symlink... Creating lab17-spoke_OVMF_VARS.fd file... Starting lab17-spoke... Waiting a second for TPM socket to be ready. --- ~> Router name : lab17-spoke.qcow2 ~> RAM size : 16384MB ~> SPICE VDI port number : 790U ~> telnet console port number : 700U ~> mgmt G1 tap interface : tapU, access mode ~> mgmt G1 IPv6 LL address : fe80::faad:caff:fefe:_UUUU_%vlan_OOB_VLAN_ID_ ~> G2 tap interface : tapV, access mode ~> G3 tap interface : tapW, access mode lab17-spoke started! Copying /home/etudianttest/masters/c8000v-universalk9.17.16.01a.qcow2 to lab17-hub.qcow2... done. Creating lab17-hub_OVMF_VARS.fd file... Starting lab17-hub... Waiting a second for TPM socket to be ready. --- ~> Router name : lab17-hub.qcow2 ~> RAM size : 16384MB ~> SPICE VDI port number : 790X ~> telnet console port number : 700X ~> mgmt G1 tap interface : tapX, access mode ~> mgmt G1 IPv6 LL address : fe80::faad:caff:fefe:_XXXX_%vlan_OOB_VLAN_ID_ ~> G2 tap interface : tapY, access mode ~> G3 tap interface : tapZ, access mode lab17-hub started! ``` :::info Note that the routers may take some time to boot because the Cisco IOS-XE system is multi-layered. ::: ### Step 3: Check the two virtual routers for SSH communication In this step, we need to verify that the two routers are up and accessible via SSH before proceeding with the minimal configuration. From the screenshot above, we can gather the Link Local IPv6 address of the two routers and test them with the `ssh` command. So we initiate SSH connections from our Devnet VM shell. ```bash ssh -q etu@fe80::faad:caff:fefe:_UUUU_%enp0s1 exit ``` ```bash= (etu@fe80::faad:caff:fefe:_UUUU_%enp0s1) Password: Connection to fe80::faad:caff:fefe:_UUUU_%enp0s1 closed by remote host. ``` ```bash echo $? ``` ```bash= 0 ``` ```bash ssh -q etu@fe80::faad:caff:fefe:_XXXX_%enp0s1 exit ``` ```bash= (etu@fe80::faad:caff:fefe:_XXXX_%enp0s1) Password: Connection to fe80::faad:caff:fefe:_XXXX_%enp0s1 closed by remote host. ``` ```bash echo $? ``` ```bash= 0 ``` ### Step 4: Set initial router configurations Now that we have access to our hub-and-spoke routers, we set up the initial configurations we want to run tests against. First, open a new SSH connection and go to the router configuration terminal: ```console rtrXXX#configure terminal Enter configuration commands, one per line. End with CNTL/Z. rtrXXX(config)# ``` Then copy and paste the spoke and hub lab configuration on each router. - Spoke router configuration ``` hostname spoke ! ipv6 unicast-routing ! username genieuser privilege 15 secret -genie- ! router ospfv3 100 router-id 0.0.0.2 address-family ipv4 unicast area 0 exit-address-family address-family ipv6 unicast area 0 exit-address-family ! interface GigabitEthernet2 no shutdown ip address 10.0.1.2 255.255.255.0 ipv6 enable ipv6 address 2001:db8:1::2/64 ospfv3 100 ipv4 area 0 ospfv3 100 ipv6 area 0 ! interface Loopback0 ip address 10.0.2.2 255.255.255.255 ipv6 enable ipv6 address 2001:db8:2::2/128 ospfv3 100 ipv4 area 0 ospfv3 100 ipv6 area 0 ! end ``` - Hub router configuration ``` hostname hub ! ipv6 unicast-routing ! username genieuser privilege 15 secret -genie- ! router ospfv3 100 router-id 0.0.0.1 address-family ipv4 unicast area 0 exit-address-family address-family ipv6 unicast area 0 exit-address-family ! interface GigabitEthernet2 no shutdown ip address 10.0.1.1 255.255.255.0 ipv6 enable ipv6 address 2001:db8:1::1/64 ospfv3 100 ipv4 area 0 ospfv3 100 ipv6 area 0 ! interface Loopback0 ip address 172.16.0.1 255.255.255.255 ipv6 enable ipv6 address 2001:db8:ac::1/128 ospfv3 100 ipv4 area 0 ospfv3 100 ipv6 area 0 ! ! Lo1 is the missing entry in the OSPFv3 configuration ! interface Loopback1 ip address 172.16.1.1 255.255.255.255 ipv6 enable ipv6 address 2001:db8:ac:1::1/128 ! end ``` Here are the common configuration items between the two configuration files: - identical authentication credentials with the privileged user **genieuser** - IPv6 routing - OSPFv3 (process ID 100) with: - Dual-stack support for both IPv4 and IPv6 address families - All interfaces configured in area 0 - Loopback0 interface with IPv4/IPv6 addressing :::warning Note that the Loopback1 interface on the hub router is not enabled for OSPFv3. This is intentional because we want a test to fail. ::: ### Step 5: View the routing tables on each router As a final step in this part, we will manually verify that OSPFv3 is working between the hub and spoke routers by looking at the presence of the IPv4 and IPv6 networks in the routing tables. - Spoke router tables ``` sh ip route ``` ```bash= Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 ia - IS-IS inter area, * - candidate default, U - per-user static route H - NHRP, G - NHRP registered, g - NHRP registration summary o - ODR, P - periodic downloaded static route, l - LISP a - application route + - replicated route, % - next hop override, p - overrides from PfR & - replicated local route overrides by connected Gateway of last resort is not set 10.0.0.0/8 is variably subnetted, 3 subnets, 2 masks C 10.0.1.0/24 is directly connected, GigabitEthernet2 L 10.0.1.2/32 is directly connected, GigabitEthernet2 C 10.0.2.2/32 is directly connected, Loopback0 172.16.0.0/32 is subnetted, 1 subnets O 172.16.0.1 [110/1] via 10.0.1.1, 00:39:38, GigabitEthernet2 ``` ``` sh ipv6 route ``` ```bash= IPv6 Routing Table - default - 5 entries Codes: C - Connected, L - Local, S - Static, U - Per-user Static route B - BGP, R - RIP, H - NHRP, HG - NHRP registered Hg - NHRP registration summary, HE - NHRP External, I1 - ISIS L1 I2 - ISIS L2, IA - ISIS interarea, IS - ISIS summary, D - EIGRP EX - EIGRP external, ND - ND Default, NDp - ND Prefix, DCE - Destination NDr - Redirect, O - OSPF Intra, OI - OSPF Inter, OE1 - OSPF ext 1 OE2 - OSPF ext 2, ON1 - OSPF NSSA ext 1, ON2 - OSPF NSSA ext 2 a - Application, m - OMP C 2001:DB8:1::/64 [0/0] via GigabitEthernet2, directly connected L 2001:DB8:1::2/128 [0/0] via GigabitEthernet2, receive LC 2001:DB8:2::2/128 [0/0] via Loopback0, receive O 2001:DB8:AC::1/128 [110/1] via FE80::FAAD:CAFF:FEFE:B, GigabitEthernet2 L FF00::/8 [0/0] via Null0, receive ``` - Hub router tables ``` sh ip route ``` ```bash= Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 ia - IS-IS inter area, * - candidate default, U - per-user static route H - NHRP, G - NHRP registered, g - NHRP registration summary o - ODR, P - periodic downloaded static route, l - LISP a - application route + - replicated route, % - next hop override, p - overrides from PfR & - replicated local route overrides by connected Gateway of last resort is not set 10.0.0.0/8 is variably subnetted, 3 subnets, 2 masks C 10.0.1.0/24 is directly connected, GigabitEthernet2 L 10.0.1.1/32 is directly connected, GigabitEthernet2 O 10.0.2.2/32 [110/1] via 10.0.1.2, 00:43:49, GigabitEthernet2 172.16.0.0/32 is subnetted, 2 subnets C 172.16.0.1 is directly connected, Loopback0 C 172.16.1.1 is directly connected, Loopback1 ``` ``` sh ipv6 route ``` ```bash= IPv6 Routing Table - default - 6 entries Codes: C - Connected, L - Local, S - Static, U - Per-user Static route B - BGP, R - RIP, H - NHRP, HG - NHRP registered Hg - NHRP registration summary, HE - NHRP External, I1 - ISIS L1 I2 - ISIS L2, IA - ISIS interarea, IS - ISIS summary, D - EIGRP EX - EIGRP external, ND - ND Default, NDp - ND Prefix, DCE - Destination NDr - Redirect, O - OSPF Intra, OI - OSPF Inter, OE1 - OSPF ext 1 OE2 - OSPF ext 2, ON1 - OSPF NSSA ext 1, ON2 - OSPF NSSA ext 2 a - Application, m - OMP C 2001:DB8:1::/64 [0/0] via GigabitEthernet2, directly connected L 2001:DB8:1::1/128 [0/0] via GigabitEthernet2, receive O 2001:DB8:2::2/128 [110/1] via FE80::FAAD:CAFF:FEFE:8, GigabitEthernet2 LC 2001:DB8:AC::1/128 [0/0] via Loopback0, receive LC 2001:DB8:AC:1::1/128 [0/0] via Loopback1, receive L FF00::/8 [0/0] via Null0, receive ``` One last check is done by looking at the OSPFv3 neighbor list. ``` sh ospfv3 neighbor ``` ```bash= OSPFv3 100 address-family ipv4 (router-id 0.0.0.1) Neighbor ID Pri State Dead Time Interface ID Interface 0.0.0.2 1 FULL/DR 00:00:39 6 GigabitEthernet2 OSPFv3 100 address-family ipv6 (router-id 0.0.0.1) Neighbor ID Pri State Dead Time Interface ID Interface 0.0.0.2 1 FULL/DR 00:00:32 6 GigabitEthernet2 ``` ## Part 2: Prepare the automated test environment In this part, you will set up a new Python virtual environment dedicated to pyATS. ### Step 1: Create the working directory and Python virtual environment 1. Ensure the `$HOME/labs/lab17` directory exist and navigate to this folder ```bash mkdir -p $HOME/labs/lab17 && cd $HOME/labs/lab17 ``` 2. Install the **pyats** Python virtual environement ```bash python3 -m venv pyats source ./pyats/bin/activate pip3 install pyats[full] ``` 3. Verify that pyATS has been successfully installed ```bash pyats --help ``` ```bash= Usage: pyats <command> [options] Commands: clean runs the provided clean file create create scripts and libraries from template develop Puts desired pyATS packages into development mode diff Command to diff two snapshots saved to file or directory dnac Command to learn DNAC features and save to file (Prototype) learn Command to learn device features and save to file logs command enabling log archive viewing in local browser migrate utilities for migrating to future versions of pyATS parse Command to parse show commands run runs the provided script and output corresponding results. secret utilities for working with secret strings. shell enter Python shell, loading a pyATS testbed file and/or pickled data undevelop Removes desired pyATS packages from development mode validate utilities that help to validate input files version commands related to version display and manipulation General Options: -h, --help Show help Run 'pyats <command> --help' for more information on a command. ``` If we need to update `pyats` after cloning the git repository, we can run these commands: ``` pyats version check ``` ``` pyats version update --yes ``` ### Step 2: Create a testbed YAML file The pyATS and Genie tools use a YAML file to know which devices to connect to and what the correct credentials are. This file is called the testbed file. Genie has built-in functionality to create the testbed file for you. We start using the `genie` command by looking at the help options Enter the command `genie --help` to see all the available commands. For additional help on any command, use the `<command>` parameter, as shown below, for the create command. Note that `testbed` is one of the options for the create command. ```bash genie --help ``` ```bash= Usage: genie <command> [options] Commands: create Create Testbed, parser, triggers, ... develop Puts desired pyATS packages into development mode diff Command to diff two snapshots saved to file or directory dnac Command to learn DNAC features and save to file (Prototype) learn Command to learn device features and save to file parse Command to parse show commands run Run Genie triggers & verifications in pyATS runtime environment shell enter Python shell, loading a pyATS testbed file and/or pickled data undevelop Removes desired pyATS packages from development mode General Options: -h, --help Show help Run 'genie <command> --help' for more information on a command. ``` To create a new **testbed file**, we need to use the `--create` option, which has its own help section. ```bash genie create --help ``` ```bash= Usage: genie create <subcommand> [options] Subcommands: parser create a new Genie parser from template testbed create a testbed file automatically trigger create a new Genie trigger from template General Options: -h, --help Show help -v, --verbose Give more output, additive up to 3 times. -q, --quiet Give less output, additive up to 3 times, corresponding to WARNING, ERROR, and CRITICAL logging levels ``` Before we can create our first testbed file, we need to perform two tasks: - Add a secrets management section to the main `pyats` configuration file - Collect the two router IPv4 out-of-band interface addresses We start by adding a `[secrets]` section to the pyats configuration file: ```bash cat << 'EOF' >> ~/.pyats/pyats.conf [secrets] string.representer = pyats.utils.secret_strings.FernetSecretStringRepresenter EOF ``` Then we generate the pyats secret: ```bash pyats secret keygen ``` ```bash= Newly generated key : WVy... ``` Finally we append the secret to the pyats configuration file. ```bash echo "string.key = WVy..." >> ~/.pyats/pyats.conf ``` Now that our first task is done, we can check the contents of the configuration file. ```bash cat ~/.pyats/pyats.conf ``` ```bash= [secrets] string.representer = pyats.utils.secret_strings.FernetSecretStringRepresenter string.key = WVy... # <-- YOUR OWN SECRET ``` For the second task, we need to connect to the routers and get the name and out-of-band interface address. ```bash ssh etu@fe80::faad:caff:fefe:_UUUU_%enp0s1 ``` ```bash= spoke#sh ip int brief Interface IP-Address OK? Method Status Protocol GigabitEthernet1 198.18.CCC.UUU YES DHCP up up GigabitEthernet2 10.0.1.2 YES manual up up GigabitEthernet3 unassigned YES unset administratively down down Loopback0 10.0.2.2 YES manual up up ``` ```bash ssh etu@fe80::faad:caff:fefe:_XXXX_%enp0s1 ``` ```bash= hub#sh ip int brief Interface IP-Address OK? Method Status Protocol GigabitEthernet1 198.18.CCC.XXX YES DHCP up up GigabitEthernet2 10.0.1.1 YES manual up up GigabitEthernet3 unassigned YES unset administratively down down Loopback0 172.16.0.1 YES manual up up Loopback1 172.16.1.1 YES manual up up ``` To create your testbed YAML file, the `genie` command needs parameters. The `--output` parameter creates the `testbed.yaml` file. The `--encode-password` parameter will encode the passwords in the YAML file. The `interactive` parameter means that you will be asked a series of questions. Provide the following answers to create the `testbed.yml` file. - Device hostname : Must match the hostname of the device, which for this example is **rtrXXX** - IP address : Must match your router out-of-band interface IPv4 address - Username : Local username used for ssh, which is **genieuser** - Default password : Local username password used for ssh, which is **-genie-** - Enable password : Leave blank. There is no privileged password configured on the router - Protocol : SSH along with the key exchange group expected by the router - OS : OS on the router is IOS-XE Finally, we are ready to run the `genie create` command: ```bash genie create testbed interactive --output testbed.yaml --encode-password ``` ```bash= Start creating Testbed yaml file ... Do all of the devices have the same username? [y/n] y Common Username: genieuser Do all of the devices have the same default password? [y/n] y Common Default Password (leave blank if you want to enter on demand): Do all of the devices have the same enable password? [y/n] y Common Enable Password (leave blank if you want to enter on demand): Device hostname: spoke IP (ip, or ip:port): 198.18.CCC.UUU Protocol (ssh, telnet, ...): ssh OS (iosxr, iosxe, ios, nxos, linux, ...): iosxe More devices to add ? [y/n] y Device hostname: hub IP (ip, or ip:port): 198.18.CCC.XXX Protocol (ssh, telnet, ...): ssh OS (iosxr, iosxe, ios, nxos, linux, ...): iosxe More devices to add ? [y/n] n Testbed file generated: testbed.yaml ``` :::warning The interactive menu of this Genie testbed YAML file creation asks for a password twice. 1. The first password is the password of the **genieuser** user account created when the configuration was inserted on each router: **-genie-**. 2. The second password is left blank because we are not using the enable IOS-XE level, as our genieuser already has the maximum level of privileges. ::: Edit the `testbed.yaml` file. Make a note of your entries in the YAML file. Your SSH password is encrypted. The lines that ask for the enable password need to be **commented out** because the `genieuser` has the highest privilege level. Here is a sample copy of the `testbed.yaml` file: ```yaml= devices: hub: connections: cli: ip: 198.18.CCC.UUU protocol: ssh credentials: default: password: '%ENC{gAAA...Fyw==}' username: genieuser # enable: # password: '%ASK{}' os: iosxe type: cat8k spoke: connections: cli: ip: 198.18.CCC.XXX protocol: ssh credentials: default: password: '%ENC{gAAAA...qBw==}' username: genieuser # enable: # password: '%ASK{}' os: iosxe type: cat8k ``` ### Step 3: Use Genie to parse output from the `show ip interface brief` command into JSON :::info Converting unstructured output data from Cisco IOS-XE into structured data is critical for operational test automation. By structuring data, organizations can streamline their test processes, reduce manual errors, and increase the reliability of network automation tasks. This transformation supports more accurate and efficient network management. ::: Using your testbed YAML file, invoke Genie to parse the unstructured output of the `show ip interface brief` command into structured JSON. This command includes the IOS command to be parsed (`show ip interface brief`), the YAML testbed file (`testbed.yaml`), which contains the device list. ```bash genie parse "show ip interface brief" --testbed-file testbed.yaml --devices spoke hub ``` The results of this command are two JSON-formatted dictionaries of network interfaces: the structured data representation of the network configuration. - Spoke router interfaces ```json= { "interface": { "GigabitEthernet1": { "interface_is_ok": "YES", "ip_address": "198.18.54.7", "method": "DHCP", "protocol": "up", "status": "up" }, "GigabitEthernet2": { "interface_is_ok": "YES", "ip_address": "10.0.1.2", "method": "manual", "protocol": "up", "status": "up" }, "GigabitEthernet3": { "interface_is_ok": "YES", "ip_address": "unassigned", "method": "unset", "protocol": "down", "status": "administratively down" }, "Loopback0": { "interface_is_ok": "YES", "ip_address": "10.0.2.2", "method": "manual", "protocol": "up", "status": "up" } } } ``` - Hub router interfaces ```json= { "interface": { "GigabitEthernet1": { "interface_is_ok": "YES", "ip_address": "198.18.54.10", "method": "DHCP", "protocol": "up", "status": "up" }, "GigabitEthernet2": { "interface_is_ok": "YES", "ip_address": "10.0.1.1", "method": "manual", "protocol": "up", "status": "up" }, "GigabitEthernet3": { "interface_is_ok": "YES", "ip_address": "unassigned", "method": "unset", "protocol": "down", "status": "administratively down" }, "Loopback0": { "interface_is_ok": "YES", "ip_address": "172.16.0.1", "method": "manual", "protocol": "up", "status": "up" }, "Loopback1": { "interface_is_ok": "YES", "ip_address": "172.16.1.1", "method": "manual", "protocol": "up", "status": "up" } } } ``` ## Part 3: Create a first pyATS Python testing script In this part, we will demonstrate how network engineers can automate the collection of device information and store it in a machine-readable format for further analysis or documentation. ### Step 1: Review Testing Fundamentals Tests should follow a classic pattern: 1. Arrange : Prepare your environment for the test. This task may involve collecting data, setting variables, connecting to a device, or a combination of steps. 2. Act : Perform an action to affect the environment. For example, call a function or invoke an API. 3. Assert : Test whether an expected condition is met. If so, the test passes. Otherwise, the test fails. 4. Cleanup : An optional stage to perform any necessary post-test tasks. Creating tests for network infrastructure with pyATS is no different. Before creating your first tests, review the usage of Python assertions and how they will be used for testing your network. An assertion is used to test that a condition evaluates to true. The syntax is: > assert statement to test, "Message to be printed if False" When an assertion evaluates to `False`, an exception (error) of type AssertionError will be raised (created). If the assertion evaluates to `True`, no message will be printed and the testing will continue. pyATS automatically handles exceptions of type AssertionError and interprets the exception as a test failure (FAILED). If the assertion results in True, the test will be marked as PASSED. - Example of an assertion that evaluates to False: ```python python -c "assert True is False, 'True does not equal False.'" ``` ```python= Traceback (most recent call last): File "<string>", line 1, in <module> AssertionError: True does not equal False. ``` Verify that Python exited abnormally by checking the exit code returned to the shell. Zero (0) is a successful exit status, any other value is considered a failure: ```bash echo $? ``` ```bash= 1 ``` - Example of an assertion that evaluates to True: ```python python -c "assert True is True, 'This message should never be printed'" ``` You should just see another prompt indicating that the assertion was true and therefore no exception was raised. If you want to see that the script exit code was 0 (successful), you can run the following code to see the exit code: ```bash echo $? ``` ```bash= 0 ``` Now that we understand the importance of assertions in testing, we are ready for the next steps: **Arrange** and then **Act**. ### Step 2: Create a Python script to parse `show version` as the "Arrange" phase We want a Python script using the pyATS framework to connect to a network device named **spoke** defined in the testbed YAML file. After setting up logging and loading the testbed configuration, this script connects to the device and learns the platform characteristics. The script then executes the `show version` command on the router, parses the structured output using pyATS's parsing capabilities, and saves the results as formatted JSON in a file named `show_version.json`. Here is our first Python script code, in which you need to edit the device name. ```python= #!/usr/bin/env python import json import logging import sys from pyats.topology import loader # Set up logging logging.basicConfig(stream=sys.stdout, level=logging.INFO) # Load the testbed file testbed = loader.load("testbed.yaml") device = testbed.devices.spoke # <-- YOUR OWN ROUTER HOSTNAME # Connect to the device and learn the current platform attributes device.connect(learn_tokens=True, overwrite_testbed_tokens=True) # Open a connection to the device device.connect() # Parse the 'show version' command and save to file with open("show_version.json", "w") as f: json.dump(device.parse("show version"), f, indent=2) ``` Let's run our new pyATS Python code named `01_parse_show_version.py`: ```bash python3 01_parse_show_version.py ``` The console output of this command is far too long to reproduce here. It shows that the Python script has learned and collected all the characteristics of the router platform. These operations are similar to the **gather_facts** phase of an Ansible playbook. This console output provides timestamps and unstructured IOS-XE data. Here is a snippet showing the configuration tokens learned after connecting to the target device. | Token Type | Defined in Testbed | Learned from Device | Used for this job | |--------------|--------------------|---------------------|-------------------| | os | iosxe | iosxe | iosxe | | os_flavor | None | None | None | | version | None | 17.16.1a | 17.16.1a | | platform | cat8k | cat8k | cat8k | | model | None | c8000v | c8000v | | submodel | None | None | None | | pid | None | C8000V | C8000V | | chassis_type | None | None | None | The Python script writes the result of the `show version` command to the `show_version.json` file. This JSON file contains the structured data from the command output. ```json= { "version": { "xe_version": "17.16.01a", "version_short": "17.16", "platform": "Virtual XE", "version": "17.16.1a", "image_id": "X86_64_LINUX_IOSD-UNIVERSALK9-M", "label": "RELEASE SOFTWARE (fc1)", "os": "IOS-XE", "location": "IOSXE", "image_type": "production image", "copyright_years": "1986-2024", "compiled_date": "Thu 19-Dec-24 18:08", "compiled_by": "mcpre", "rom": "IOS-XE ROMMON", "hostname": "rtrXXX", "uptime": "2 days, 15 hours, 38 minutes", "uptime_this_cp": "2 days, 15 hours, 39 minutes", "returned_to_rom_by": "reload", "returned_to_rom_at": "17:23:18 WEST Tue Jan 14 2025", "system_restarted_at": "18:39:09 WEST Thu Mar 13 2025", "system_image": "bootflash:packages.conf", "last_reload_reason": "reload", "license_type": "Perpetual", "chassis": "C8000V", "main_mem": "1889922", "processor_type": "VXE", "rtr_type": "C8000V", "chassis_sn": "9J3WSJZRVVP", "router_operating_mode": "Autonomous", "number_of_intfs": { "Gigabit Ethernet": "3" }, "mem_size": { "non-volatile configuration": "32768", "physical": "16308192" }, "disks": { "bootflash:.": { "disk_size": "28100576", "type_of_disk": "virtual hard disk" } }, "curr_config_register": "0x2102" } } ``` ## Part 4: Configuration State Testing In this part, we will create a pyATS script to test the configuration state of OSPFv3 on our hub and spoke routers. We focus on testing the configuration state of OSPFv3 on our hub and spoke routers using pyATS. The script `02_ospfv3_config_state_test.py` demonstrates a comprehensive approach to configuration testing, covering all three phases of the testing process: Arrange : The script sets up the test environment by connecting to the devices in the testbed and learning their configurations. This is done in the `CommonSetup` class and the `setup` method of the `TestOSPFv3ConfigState` class. Act : While there is no explicit "act" phase in configuration testing (since we don't change the device state), the script effectively "acts" by parsing and extracting the relevant OSPFv3 configuration from the learned device configuration. Assert : The bulk of the script focuses on assertions, verifying various aspects of the OSPFv3 configuration. This includes verifying that the OSPFv3 process is configured, validating the presence of IPv4 and IPv6 address families, and ensuring that OSPFv3 is properly configured on specific interfaces. These assertions are implemented in the test methods of the TestOSPFv3ConfigState class. This script provides a framework for ensuring that the OSPFv3 configuration on the devices matches the expected state, covering key aspects of the protocol's setup across multiple network elements. ### Step 1: Create the configuration test script code Here is the test script code: ```python= #!/usr/bin/env python import json import logging from genie.harness.base import Trigger from pyats import aetest # Logging configuration logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class CommonSetup(aetest.CommonSetup): """Common setup tasks for the test script""" @aetest.subsection def connect_to_devices(self, testbed): """Connect to all devices in the testbed""" testbed.connect(log_stdout=False) @aetest.subsection def prepare_testcases(self, testbed): """Prepare testcases by generating parameters for each device""" # Devices to test devices = ["spoke", "hub"] device_list = [] for device_name in devices: if device_name in testbed.devices: device = testbed.devices[device_name] # Learn device configuration device_config = device.learn("config") # Add the device to our list device_list.append( { "device_name": device_name, "device": device, "running_config": device_config, } ) # Use loop.mark with uid_keys to properly pass parameters aetest.loop.mark( TestOSPFv3ConfigState, device_info=device_list, uid_keys=("device_name",) ) class TestOSPFv3ConfigState(Trigger): """ Test trigger for OSPFv3 configuration state """ @aetest.setup def setup(self, device_info): """ Setup for this specific device test instance """ self.device_name = device_info["device_name"] self.device = device_info["device"] self.running_config = device_info["running_config"] logger.info(f"Starting tests for device: {self.device_name}") # Extract OSPFv3 configuration expected_config_section = "router ospfv3 100" logger.info(f"Verifying the presence of '{expected_config_section}'") self.ospfv3_config = self.running_config.get(expected_config_section, None) logger.info( f"OSPFv3 Configuration for {self.device_name}:\n{json.dumps(self.ospfv3_config, indent=2)}" ) @aetest.test def test_ospfv3_process_configured(self): """ Verifies that the OSPFv3 process is configured on the device. """ # trunk-ignore(bandit/B101) assert ( self.ospfv3_config is not None ), f"The OSPFv3 process is not configured on {self.device_name}" @aetest.test def test_ospfv3_address_families(self): """ Verifies that IPv4 and IPv6 address families are configured for OSPFv3. """ for address_family in ["ipv4", "ipv6"]: logger.info( f"Verifying address family {address_family} on {self.device_name}" ) expected_config = f"address-family {address_family} unicast" logger.info(f"Checking for the presence of '{expected_config}'") # trunk-ignore(bandit/B101) assert ( expected_config in self.ospfv3_config ), f"Address family {address_family} is not configured for OSPFv3 on {self.device_name}" @aetest.test def test_ospfv3_interfaces(self): """ Verifies OSPFv3 configuration on interfaces. """ interfaces_to_check = ["GigabitEthernet2", "Loopback0"] for interface in interfaces_to_check: logger.info(f"Verifying interface {interface} on {self.device_name}") interface_config = self.running_config.get(f"interface {interface}", {}) logger.info( f"Interface {interface} configuration on {self.device_name}:\n{json.dumps(interface_config, indent=2)}" ) # trunk-ignore(bandit/B101) assert ( "ospfv3 100 ipv4 area 0" in interface_config ), f"OSPFv3 IPv4 is not configured on {interface} for {self.device_name}" # trunk-ignore(bandit/B101) assert ( "ospfv3 100 ipv6 area 0" in interface_config ), f"OSPFv3 IPv6 is not configured on {interface} for {self.device_name}" class CommonCleanup(aetest.CommonCleanup): """Common cleanup tasks for the test script""" @aetest.subsection def disconnect_devices(self, testbed): """Disconnect from all devices in the testbed""" testbed.disconnect() if __name__ == "__main__": from genie.testbed import load testbed = load("testbed.yaml") aetest.main(testbed=testbed) ``` The script output is very verbose, but we are able to identify the list of individual tests that passed or failed. ```bash python3 02_test_ospfv3_config.py ``` Here is a snippet of the test output without timestamps: ```bash= AETEST-INFO: +------------------------------------------------------------------------------+ AETEST-INFO: | Detailed Results | AETEST-INFO: +------------------------------------------------------------------------------+ INFO:pyats.aetest.reporter.default: SECTIONS/TESTCASES RESULT AETEST-INFO: SECTIONS/TESTCASES RESULT INFO:pyats.aetest.reporter.default:-------------------------------------------------------------------------------- AETEST-INFO: -------------------------------------------------------------------------------- INFO:pyats.aetest.reporter.default:. |-- common_setup PASSED | |-- connect_to_devices PASSED | `-- prepare_testcases PASSED |-- TestOSPFv3ConfigState[device_info={'device_name':_'spoke',_'dev... PASSED | |-- setup PASSED | |-- test_ospfv3_process_configured PASSED | |-- test_ospfv3_address_families PASSED | `-- test_ospfv3_interfaces PASSED |-- TestOSPFv3ConfigState[device_info={'device_name':_'hub',_'devic... PASSED | |-- setup PASSED | |-- test_ospfv3_process_configured PASSED | |-- test_ospfv3_address_families PASSED | `-- test_ospfv3_interfaces PASSED `-- common_cleanup PASSED `-- disconnect_devices PASSED AETEST-INFO: . AETEST-INFO: |-- common_setup PASSED AETEST-INFO: | |-- connect_to_devices PASSED AETEST-INFO: | `-- prepare_testcases PASSED AETEST-INFO: |-- TestOSPFv3ConfigState[device_info={'device_name':_'spoke',_'dev... PASSED AETEST-INFO: | |-- setup PASSED AETEST-INFO: | |-- test_ospfv3_process_configured PASSED AETEST-INFO: | |-- test_ospfv3_address_families PASSED AETEST-INFO: | `-- test_ospfv3_interfaces PASSED AETEST-INFO: |-- TestOSPFv3ConfigState[device_info={'device_name':_'hub',_'devic... PASSED AETEST-INFO: | |-- setup PASSED AETEST-INFO: | |-- test_ospfv3_process_configured PASSED AETEST-INFO: | |-- test_ospfv3_address_families PASSED AETEST-INFO: | `-- test_ospfv3_interfaces PASSED AETEST-INFO: `-- common_cleanup PASSED AETEST-INFO: `-- disconnect_devices PASSED INFO:pyats.aetest.reporter.default:+------------------------------------------------------------------------------+ | Summary | +------------------------------------------------------------------------------+ AETEST-INFO: +------------------------------------------------------------------------------+ AETEST-INFO: | Summary | AETEST-INFO: +------------------------------------------------------------------------------+ INFO:pyats.aetest.reporter.default: Number of ABORTED 0 INFO:pyats.aetest.reporter.default: Number of BLOCKED 0 INFO:pyats.aetest.reporter.default: Number of ERRORED 0 INFO:pyats.aetest.reporter.default: Number of FAILED 0 INFO:pyats.aetest.reporter.default: Number of PASSED 4 INFO:pyats.aetest.reporter.default: Number of PASSX 0 INFO:pyats.aetest.reporter.default: Number of SKIPPED 0 INFO:pyats.aetest.reporter.default: Total Number 4 INFO:pyats.aetest.reporter.default: Success Rate 100.0% INFO:pyats.aetest.reporter.default:-------------------------------------------------------------------------------- AETEST-INFO: -------------------------------------------------------------------------------- ``` ### Step 2: Insert another configuration state test From the previous step, we know that our configuration state test script works. However, it seems that the test for the OSPF daemon router_id is missing. So we need to add this new test to the script code. Locate the `TestOSPFv3ConfigState` class and add a `test_ospfv3_router_id_configured` method after the `test_ospfv3_process_configured` method. Here is the code for the new method: ```python= @aetest.test def test_ospfv3_router_id_configured(self): """ Verifies that the router-id is configured for OSPFv3. """ logger.info(f"Verifying router-id configuration on {self.device_name}") # Check if router-id configuration exists in the OSPFv3 config router_id_configured = False router_id_value = None for config_line in self.ospfv3_config: if config_line.startswith("router-id"): router_id_configured = True router_id_value = config_line.split(" ")[1] break logger.info(f"Router-ID on {self.device_name}: {router_id_value}") # trunk-ignore(bandit/B101) assert ( router_id_configured ), f"Router-ID is not configured for OSPFv3 on {self.device_name}" # trunk-ignore(bandit/B101) assert ( router_id_value is not None ), f"Router-ID value is not set for OSPFv3 on {self.device_name}" ``` Run the Python test script again and locate the test tree output. ```bash python3 02_ospfv3_config_state_test.py ``` Here is the output snippet showing that our new test is being processed: ```bash= INFO:pyats.aetest.reporter.default:. |-- common_setup PASSED | |-- connect_to_devices PASSED | `-- prepare_testcases PASSED |-- TestOSPFv3ConfigState[device_info={'device_name':_'spoke',_'dev... PASSED | |-- setup PASSED | |-- test_ospfv3_process_configured PASSED | |-- test_ospfv3_router_id_configured PASSED | |-- test_ospfv3_address_families PASSED | `-- test_ospfv3_interfaces PASSED |-- TestOSPFv3ConfigState[device_info={'device_name':_'hub',_'devic... PASSED | |-- setup PASSED | |-- test_ospfv3_process_configured PASSED | |-- test_ospfv3_router_id_configured PASSED | |-- test_ospfv3_address_families PASSED | `-- test_ospfv3_interfaces PASSED `-- common_cleanup PASSED `-- disconnect_devices PASSED ``` ## Part 5: Operational State Testing Configuration state tests are very useful, and are common in network automation. For example, Ansible tests that a configuration stanza exists (or doesn't exist) when performing idempotence checks in a playbook run. Configuration state testing has one important limitation: Configuration state testing does not indicate if a feature is operating as expected! A real-world example of this condition can be demonstrated using a smartphone. At the end of the day, the battery is low and needs to be charged. You plug the phone into the charger and walk away, expecting a full battery when you return. - The **desired state** is that the phone is charging. - The **configured state** is that the phone is charging. - The **operational state** is that the phone is not charging, because the state of the power supply was not tested! It is important that you test the **operational state** of your network. A state is never achieved unless it is configured, and the configuration can change over time in ways that affect the operational state. For this part, we will take a multi-file approach to pyATS testing, as this improves maintainability, flexibility, and scalability. Job File : Job files are the entry point for executing tests. They define which triggers to execute and how to configure the test environment. This separation ensures that the test logic remains independent of the execution details. Trigger File : Trigger files contain the actual test cases. They define what to test and how to test it. Triggers can be reused in different jobs, making them very versatile. Data File : Data files provide a way to parameterize tests. They allow you to specify which devices to test, which attributes to check, and other test-specific details without modifying the trigger code. ### Step 1: Create the job file Here is the code for the job file named `03_op_state_job.py`. As mentioned above, it specifies trigger (`TestOSPFv3State`) and data files. ```python= #!/usr/bin/env python """ OSPFv3 operational state test job file """ # Import the gRun function to run job with datafiles from genie.harness.main import gRun def main(): """ Main function that runs the job using gRun """ # Run the job using gRun with trigger datafile gRun( trigger_uids=["TestOSPFv3State"], trigger_datafile="ospfv3_op_state_datafile.yml", subsection_datafile="subsection_datafile.yml", loglevel="INFO", ) if __name__ == "__main__": main() ``` ### Step 2: Create the trigger file The Python script named `03_ospfv3_op_state_test.py` is the heart of the operational state tests. The purpose here is to evaluate the OSPF routing protocol relationship between the two hub and spoke routers. Therefore, it contains the following tests: OSPFv3 Neighbor Relationships (test_ospfv3_neighbors) : - Verifies that OSPFv3 neighbor relationships are established - Checks for the presence of "FULL" state neighbors in the output - Confirms at least one neighbor is in "FULL" state - Applies to both hub and spoke devices OSPFv3 Route Installation (test_ospfv3_routes) : - For spoke device: Checks that the route to hub's Loopback0 IPv6 address exists in routing table - For hub device: Checks that the route to spoke's Loopback0 IPv6 address exists in routing table - Verifies routes are properly installed via OSPFv3 - Uses show ipv6 route ospf command to verify route presence IPv4 Route Presence Check (test_missing_route_ipv4) : - Verifies that the route to hub's Loopback1 IPv4 address is present in the spoke's routing table - Uses `show ip route ospfv3` command to check route presence - Test passes if the route is present, fails if it's absent - Only applicable to the spoke device IPv6 Route Presence Check (test_missing_route_ipv6) : - Verifies that the route to hub's Loopback1 IPv6 address is present in the spoke's routing table - Uses show ipv6 route ospf command to check route presence - Test passes if the route is present, fails if it's absent - Only applicable to the spoke device ```python= #!/usr/bin/env python import logging from genie.harness.base import Trigger from pyats import aetest logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class TestOSPFv3State(Trigger): """ Test trigger for OSPFv3 operational state """ @aetest.test def test_ospfv3_neighbors(self, uut): """ Verifies that the device has established OSPFv3 neighbor relationships. """ neighbor_list_output = uut.execute("show ospfv3 neighbor") logger.info(f"OSPFv3 Neighbors Output on {uut.name}:\n{neighbor_list_output}") has_full_neighbors = "FULL" in neighbor_list_output # trunk-ignore(bandit/B101) assert ( has_full_neighbors ), f"No OSPFv3 neighbor with FULL state found on {uut.name}" neighbor_count = neighbor_list_output.count("FULL") logger.info(f"Number of OSPFv3 neighbors in FULL state: {neighbor_count}") # trunk-ignore(bandit/B101) assert ( neighbor_count > 0 ), f"No OSPFv3 neighbor with FULL state found on {uut.name}" @aetest.test def test_ospfv3_routes(self, uut): """ Verifies OSPFv3 routes are properly installed in the routing table. """ try: if uut.name == "spoke": # Test spoke has a route to hub's Loopback0 expected_route_ipv6 = getattr( uut, "hub_loopback0_ipv6", "2001:DB8:AC::1" ).split("/")[0] spoke_raw_output = uut.execute("show ipv6 route ospf") logger.info(f"IPv6 OSPF routes on {uut.name}:\n{spoke_raw_output}") # trunk-ignore(bandit/B101) assert ( expected_route_ipv6 in spoke_raw_output ), f"Route to hub's Loopback0 ({expected_route_ipv6}) not found on spoke" logger.info( f"Route to hub's Loopback0 ({expected_route_ipv6}) found on spoke" ) elif uut.name == "hub": # Test hub has a route to spoke's Loopback0 expected_route_ipv6 = getattr( uut, "spoke_loopback0_ipv6", "2001:DB8:2::2" ).split("/")[0] hub_raw_output = uut.execute("show ipv6 route ospf") logger.info(f"IPv6 OSPF routes on {uut.name}:\n{hub_raw_output}") # trunk-ignore(bandit/B101) assert ( expected_route_ipv6 in hub_raw_output ), f"Route to spoke's Loopback0 ({expected_route_ipv6}) not found on hub" logger.info( f"Route to spoke's Loopback0 ({expected_route_ipv6}) found on hub" ) except Exception as e: logger.error(f"Error in test_ospfv3_routes: {e}") self.failed(f"Test failed due to error: {e}") @aetest.test def test_missing_route_ipv4(self, uut): """ Verifies the presence of specific IPv4 routes in the OSPF routing table. This method tests that the route to the hub's Loopback1 is present in the spoke's IPv4 OSPF routing table. """ if uut.name != "spoke": self.skipped("This test only applies to the spoke device") return # Use the command for IPv4 spoke_raw_output = uut.execute("show ip route ospfv3") logger.info(f"IPv4 OSPF routes on {uut.name}:\n{spoke_raw_output}") # Verify the presence of IPv4 route to hub's Loopback1 # Use getattr with a default value for the attribute hub_loopback1_ipv4 = getattr(uut, "hub_loopback1_ipv4", "172.16.1.1") # trunk-ignore(bandit/B101) assert ( hub_loopback1_ipv4 in spoke_raw_output ), f"IPv4 route to hub's Loopback1 ({hub_loopback1_ipv4}) not found in spoke routing table when it should be present" logger.info( f"IPv4 route to hub's Loopback1 ({hub_loopback1_ipv4}) correctly present in spoke routing table" ) @aetest.test def test_missing_route_ipv6(self, uut): """ Verifies the presence of specific IPv6 routes in the OSPF routing table. This method tests that the route to the hub's Loopback1 is present in the spoke's IPv6 OSPF routing table. """ if uut.name != "spoke": self.skipped("This test only applies to the spoke device") return # Use the command for IPv6 spoke_raw_output = uut.execute("show ipv6 route ospf") logger.info(f"IPv6 OSPF routes on {uut.name}:\n{spoke_raw_output}") # Verify the presence of IPv6 route to hub's Loopback1 # Use getattr with a default value for the attribute hub_loopback1_ipv6 = getattr(uut, "hub_loopback1_ipv6", "2001:DB8:AC:1::1/128") # Extract only the address without the prefix if needed if "/" in hub_loopback1_ipv6: hub_loopback1_ipv6 = hub_loopback1_ipv6.split("/")[0] # trunk-ignore(bandit/B101) assert ( hub_loopback1_ipv6 in spoke_raw_output ), f"IPv6 route to hub's Loopback1 ({hub_loopback1_ipv6}) not found in spoke routing table when it should be present" logger.info( f"IPv6 route to hub's Loopback1 ({hub_loopback1_ipv6}) correctly present in spoke routing table" ) if __name__ == "__main__": from genie.harness.main import gRun from genie.testbed import load # Load the testbed testbed = load("testbed.yaml") # Run with trigger datafile gRun( trigger_uids=["TestOSPFv3State"], trigger_datafile="ospfv3_op_state_datafile.yml", subsection_datafile="subsection_datafile.yml", ) ``` ### Step 3: Create the data files In teh context of this file datas are split in two files: - `subsection_datafile.yml` This file defines the standard setup and cleanup procedures for test execution, handling the connection to devices at the beginning of the tests and ensuring proper disconnection at the end, which helps to maintain consistent initialization and cleanup of the test environment across test runs. ```yaml= --- setup: sections: connect: method: genie.harness.commons.connect order: - connect cleanup: sections: disconnect: method: genie.harness.commons.disconnect order: - disconnect ``` - `ospfv3_op_state_datafile.yml` This file defines the test parameters and configuration for OSPFv3 Operational State Verification, specifying the test class to run, the target devices (hub and spoke), and device-specific attributes such as process IDs and interface IP addresses required for route verification tests. ```yaml= --- extends: ospfv3_base_datafile.yml TestOSPFv3State: source: class: 03_ospfv3_op_state_test.TestOSPFv3State groups: [test_ospfv3_op_state] devices: [spoke, hub] devices_attributes: spoke: ospfv3_process_id: 100 hub_loopback0_ipv4: 172.16.0.1 hub_loopback0_ipv6: 2001:DB8:AC::1/128 hub_loopback1_ipv4: 172.16.1.1 hub_loopback1_ipv6: 2001:DB8:AC:1::1/128 hub: ospfv3_process_id: 100 spoke_loopback0_ipv4: 172.16.2.2 spoke_loopback0_ipv6: 2001:DB8:2::2/128 ``` :::warning The Loopback1 interface on the hub router is the one that is intentionally misconfigured. Although it is addressed, its route is not known to the spoke router because this interface has not been added to OSPF area 0. This leads to a test failure when running the pyATS job. ::: ### Step 4: Run the pyATS job Now that all the job, trigger, and data files are in place, it is time to run the pyATS job with all the defined tests. ```bash pyats run job 03_op_state_job.py --testbed-file testbed.yaml ``` As usual, the command output is very verbose and it would take too much space in this document to reproduce it. So here is a snippet of the task results. ```bash= +--------------------------------------------------------------------+ | Task Result Details | +--------------------------------------------------------------------+ Task-1: genie_testscript FAILED |-- common_setup PASSED | `-- connect PASSED |-- TestOSPFv3State.spoke FAILED | |-- test_ospfv3_neighbors PASSED | |-- test_ospfv3_routes PASSED | |-- test_missing_route_ipv4 FAILED | `-- test_missing_route_ipv6 FAILED |-- TestOSPFv3State.hub PASSED | |-- test_ospfv3_neighbors PASSED | |-- test_ospfv3_routes PASSED | |-- test_missing_route_ipv4 SKIPPED | `-- test_missing_route_ipv6 SKIPPED `-- common_cleanup PASSED `-- disconnect PASSED ``` As expected, the tests on the spoke router are marked as failed. Specifically, the IPv4 and IPV6 missing route tests failed. We can get a more detailed view of the logs by using the built-in web service. ```bash pyats logs view ``` ```bash= Logfile: /home/etu/.pyats/archive/25-03/03_op_state_job.2025Mar20_08:03:47.204062.zip View at: http://localhost:37567/ Press Ctrl-C to exit ``` When we open the web page, we see the same view of the task results. ![pyATS logs built-in web service](https://md.inetdoc.net/uploads/da411c39-5ce7-4a12-a665-c4fbb1633368.png) If we click on the title of the failed test, a right panel appears and we get the details of the failure condition. ![pyATS logs failed test details](https://md.inetdoc.net/uploads/15904561-3d11-4059-bf40-ea52911e1c41.png) Here is a text copy of this particular failed test mentioning that an IPv4 route is missing on the spoke router. ```bash= 440: 2025-03-20T08:04:01: +------------------------------------------------------------------------------+ 441: 2025-03-20T08:04:01: | Starting section test_missing_route_ipv4 | 442: 2025-03-20T08:04:01: +------------------------------------------------------------------------------+ 443: 2025-03-20T08:04:01: +++ spoke with via 'cli': executing command 'show ip route ospfv3' +++ show ip route ospfv3 spoke# 463: 2025-03-20T08:04:01: IPv4 OSPF routes on spoke: 464: 2025-03-20T08:04:01: Caught an assertion failure while executing section test_missing_route_ipv4: 465: 2025-03-20T08:04:01: Traceback (most recent call last): 466: 2025-03-20T08:04:01: File "/home/etu/labs/lab17/03_ospfv3_op_state_test.py", line 104, in test_missing_route_ipv4 467: 2025-03-20T08:04:01: hub_loopback1_ipv4 in spoke_raw_output 468: 2025-03-20T08:04:01: AssertionError: IPv4 route to hub's Loopback1 (172.16.1.1) not found in spoke routing table when it should be present 469: 2025-03-20T08:04:01: The result of section test_missing_route_ipv4 is => FAILED ``` ## Part 6: Fix the hub router configuration to pass tests Although configuring devices is not the primary goal of pyATS tests, it is possible to fix our intentionally broken configuration with a new set of job, trigger, and data files. We will create these new files in this part and then re-run the job from the previous part to validate our automated test discovery journey. ### Step 1: Create the fix job, trigger, and data files As we did in the previous part, we have 3 files to create: The job script is named `04_fix_config_job.py`: ```python= #!/usr/bin/env python """ OSPFv3 configuration fix job file This job runs the trigger to fix OSPFv3 configuration on hub router by adding Loopback1 interface to area 0 for both IPv4 and IPv6 address families. """ # Import the gRun function to run job with datafiles from genie.harness.main import gRun def main(): """ Main function that runs the job using gRun """ # Run the job using gRun with trigger datafile gRun( trigger_uids=["FixOSPFv3"], trigger_datafile="ospfv3_config_fix_datafile.yml", subsection_datafile="subsection_datafile.yml", loglevel="INFO", ) if __name__ == "__main__": main() ``` The trigger script named `04_ospfv3_config_fix.py` defines the **FixOSPFv3** class which contains 4 methods: - `verify_hub_loopback1_interface()` : - Confirms that the Loopback1 interface exists on the hub router - Executes the command show ip interface brief | include Loopback1 - Verifies the presence of "Loopback1" in the command output - Fails the test if the interface is not found - Skips the test if executed on non-hub devices - `configure_loopback1_ospfv3_ipv4()` : - Configures OSPFv3 for IPv4 on the Loopback1 interface - Identifies the OSPFv3 process ID from device attributes (defaults to 100) - Applies configuration commands to add Loopback1 to OSPFv3 area 0 for IPv4 - Skips the test if executed on non-hub devices - `configure_loopback1_ospfv3_ipv6()` : - Configures OSPFv3 for IPv6 on the Loopback1 interface - Identifies the OSPFv3 process ID from device attributes (defaults to 100) - Applies configuration commands to add Loopback1 to OSPFv3 area 0 for IPv6 - Skips the test if executed on non-hub devices - `verify_configuration()` : - Validates that the OSPFv3 configuration has been successfully applied to Loopback1 - Checks the running configuration with show running-config interface Loopback1 - Verifies both IPv4 and IPv6 OSPFv3 configurations are present - Uses assertions to confirm proper configuration with the correct process ID - Skips the test if executed on non-hub devices ```python= #!/usr/bin/env python """ pyATS Trigger to fix OSPFv3 configuration on the hub router This script adds Loopback1 to OSPFv3 area 0 for both IPv4 and IPv6 address families, which enables the hub's Loopback1 routes to be advertised to the spoke router. """ import logging from genie.harness.base import Trigger from unicon.core.errors import SubCommandFailure from pyats import aetest # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class FixOSPFv3(Trigger): """ pyATS Trigger class to fix the OSPFv3 configuration on the hub router. This class ensures Loopback1 interface is added to OSPFv3 area 0 for both IPv4 and IPv6 address families. """ @aetest.test def verify_hub_loopback1_interface(self, uut): """ Verify that the Loopback1 interface exists on the hub router. Args: uut: Device object from the testbed """ if uut.name != "hub": self.skipped("This trigger is only applicable to the hub device") return # Check if Loopback1 exists try: output = uut.execute("show ip interface brief | include Loopback1") logger.info(f"Loopback1 interface status: {output}") if "Loopback1" not in output: self.failed("Loopback1 interface not found on hub router") logger.info("Loopback1 interface exists on hub router") except SubCommandFailure as err: self.failed(f"Failed to verify Loopback1 interface: {err}") @aetest.test def configure_loopback1_ospfv3_ipv4(self, uut): """ Configure OSPFv3 on Loopback1 for IPv4 address family. Args: uut: Device object from the testbed """ if uut.name != "hub": self.skipped("This trigger is only applicable to the hub device") return # Get the OSPFv3 process ID ospf_process_id = getattr(uut, "ospfv3_process_id", 100) try: # Configure OSPFv3 for IPv4 on Loopback1 area 0 config_commands = [ "interface Loopback1", f"ospfv3 {ospf_process_id} ipv4 area 0", "exit", ] logger.info( f"Configuring OSPFv3 for IPv4 on Loopback1 with process ID {ospf_process_id}" ) uut.configure(config_commands) logger.info("Successfully configured OSPFv3 for IPv4 on Loopback1") except SubCommandFailure as err: self.failed(f"Failed to configure OSPFv3 IPv4 on Loopback1: {err}") @aetest.test def configure_loopback1_ospfv3_ipv6(self, uut): """ Configure OSPFv3 on Loopback1 for IPv6 address family. Args: uut: Device object from the testbed """ if uut.name != "hub": self.skipped("This trigger is only applicable to the hub device") return # Get the OSPFv3 process ID ospf_process_id = getattr(uut, "ospfv3_process_id", 100) try: # Configure OSPFv3 for IPv6 on Loopback1 area 0 config_commands = [ "interface Loopback1", f"ospfv3 {ospf_process_id} ipv6 area 0", "exit", ] logger.info( f"Configuring OSPFv3 for IPv6 on Loopback1 with process ID {ospf_process_id}" ) uut.configure(config_commands) logger.info("Successfully configured OSPFv3 for IPv6 on Loopback1") except SubCommandFailure as err: self.failed(f"Failed to configure OSPFv3 IPv6 on Loopback1: {err}") @aetest.test def verify_configuration(self, uut): """ Verify that the configuration has been applied correctly. Args: uut: Device object from the testbed """ if uut.name != "hub": self.skipped("This trigger is only applicable to the hub device") return # Get the OSPFv3 process ID ospf_process_id = getattr(uut, "ospfv3_process_id", 100) try: # Verify configuration output = uut.execute( f"show running-config interface Loopback1 | include ospfv3 {ospf_process_id}" ) logger.info(f"Loopback1 OSPFv3 configuration: {output}") # Check if both IPv4 and IPv6 are configured ipv4_configured = f"ospfv3 {ospf_process_id} ipv4 area 0" in output ipv6_configured = f"ospfv3 {ospf_process_id} ipv6 area 0" in output # Assert both are configured # trunk-ignore(bandit/B101) assert ( ipv4_configured ), f"OSPFv3 IPv4 not configured on Loopback1 with process ID {ospf_process_id}" # trunk-ignore(bandit/B101) assert ( ipv6_configured ), f"OSPFv3 IPv6 not configured on Loopback1 with process ID {ospf_process_id}" logger.info("Loopback1 OSPFv3 configuration verified successfully") except SubCommandFailure as err: self.failed(f"Failed to verify OSPFv3 configuration on Loopback1: {err}") if __name__ == "__main__": from genie.harness.main import gRun from genie.testbed import load # Load the testbed testbed = load("testbed.yaml") # Run with trigger datafile gRun( trigger_datafile="ospfv3_config_fix_datafile.yml", trigger_uids=["FixOSPFv3"], testbed=testbed, subsection_datafile="subsection_datafile.yml", ) ``` Finally, the `ospfv3_config_fix_datafile.yml` datafile specifies the class of tests to use. ```python= --- extends: ospfv3_base_datafile.yml FixOSPFv3: source: class: 04_ospfv3_config_fix.FixOSPFv3 groups: [fix_ospfv3_config] devices: [hub] devices_attributes: hub: ospfv3_process_id: 100 ``` ### Step 2: Run the fix pyATS job We are ready to run the new job when all three files are in place. ```bash pyats run job 04_fix_config_job.py --testbed-file testbed.yaml ``` As we did in the previous part, we access the results via the built-in web service. ```bash pyats logs view ``` ![Fix results](https://md.inetdoc.net/uploads/384578da-5c95-4b39-a494-90c6a1001eef.png) The screenshot above shows the configuration command being used to add the Loopback1 interface to OSPFv3 area 0. ### Step 3: Re-run the operational state test job This new job run shows that all of the network prefix exchange **operational state** tests for our two routers are now successful. ```bash pyats run job 03_op_state_job.py --testbed-file testbed.yaml ``` The results dashboard shows that all tests have passed. ![All operational state tests are successful](https://md.inetdoc.net/uploads/d7b9b1ac-15d3-42c4-b7fc-6e2550f8f2c5.png) The two routes advertised by the hub router are present in the spoke router table. ``` spoke# 502: 2025-03-20T08:47:47: IPv4 OSPF routes on spoke: 503: 2025-03-20T08:47:47: Codes: L - local, C - connected, S - static, R - RIP, M - mobile, B - BGP 504: 2025-03-20T08:47:47: D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area 505: 2025-03-20T08:47:47: N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2 506: 2025-03-20T08:47:47: E1 - OSPF external type 1, E2 - OSPF external type 2, m - OMP 507: 2025-03-20T08:47:47: n - NAT, Ni - NAT inside, No - NAT outside, Nd - NAT DIA 508: 2025-03-20T08:47:47: i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2 509: 2025-03-20T08:47:47: ia - IS-IS inter area, * - candidate default, U - per-user static route 510: 2025-03-20T08:47:47: H - NHRP, G - NHRP registered, g - NHRP registration summary 511: 2025-03-20T08:47:47: o - ODR, P - periodic downloaded static route, l - LISP 512: 2025-03-20T08:47:47: a - application route 513: 2025-03-20T08:47:47: + - replicated route, % - next hop override, p - overrides from PfR 514: 2025-03-20T08:47:47: & - replicated local route overrides by connected 515: 2025-03-20T08:47:47: 516: 2025-03-20T08:47:47: Gateway of last resort is not set 517: 2025-03-20T08:47:47: 518: 2025-03-20T08:47:47: 172.16.0.0/32 is subnetted, 2 subnets 519: 2025-03-20T08:47:47: O 172.16.0.1 [110/1] via 10.0.1.1, 1d22h, GigabitEthernet2 520: 2025-03-20T08:47:47: O 172.16.1.1 [110/1] via 10.0.1.1, 00:08:00, GigabitEthernet2 521: 2025-03-20T08:47:47: IPv4 route to hub's Loopback1 (172.16.1.1) correctly present in spoke routing table 522: 2025-03-20T08:47:47: The result of section test_missing_route_ipv4 is => PASSED ``` ## Conclusion This document outlines a comprehensive approach to automating network testing using pyATS and Genie, with a focus on an OSPFv3 protocol exchange between two routers. Although pyATS is a Cisco tool, the need for automation and the methods used here are universally applicable to various network environments. The lab setup and test procedures demonstrate how automation can streamline network validation and efficiently ensure compliance with desired states. This approach is beneficial for any network infrastructure, regardless of vendor.