diff --git a/1-reading-network-configuration/.gitkeep b/1-reading-network-configuration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/1-reading-network-configuration/README.md b/1-reading-network-configuration/README.md new file mode 100644 index 0000000..e69de29 diff --git a/1-reading-network-configuration/answers/exercise1/1_netmiko_show_interfaces_raw.py b/1-reading-network-configuration/answers/exercise1/1_netmiko_show_interfaces_raw.py new file mode 100644 index 0000000..55bb51b --- /dev/null +++ b/1-reading-network-configuration/answers/exercise1/1_netmiko_show_interfaces_raw.py @@ -0,0 +1,29 @@ +# pip install --user netmiko +from netmiko import Netmiko + +username = "clab" +password = "clab@123" +device_type = "cisco_xr" +hosts = ["172.16.30.2", "172.16.30.3"] +command_to_run = "show int brief" + +for host in hosts: + # Create a variable that represents an SSH connection to our router. + connection = Netmiko( + username=username, + password=password, + device_type=device_type, + ip=host + ) + + # Send a command to the router, and get back the raw output + raw_output = connection.send_command(command_to_run) + + # The "really raw" output has '\n' characters appear instead of a real carriage return. + # Converting them into carriage returns will make it a little more readable for this demo. + raw_output = raw_output.replace("\\n", "\n") + + print( + f"### This is the raw output from {host}, without any parsing: ###\n", + raw_output + "\n" + ) diff --git a/1-reading-network-configuration/answers/exercise1/2_netmiko_show_interfaces_textfsm.py b/1-reading-network-configuration/answers/exercise1/2_netmiko_show_interfaces_textfsm.py new file mode 100644 index 0000000..90ca320 --- /dev/null +++ b/1-reading-network-configuration/answers/exercise1/2_netmiko_show_interfaces_textfsm.py @@ -0,0 +1,32 @@ +# pip install --user textfsm +# pip install --user netmiko +import json +from netmiko import Netmiko +from pprint import pprint + +username = "clab" +password = "clab@123" +device_type = "cisco_xr" +hosts = ["172.16.30.2", "172.16.30.3"] +command_to_run = "show int brief" + +for host in hosts: + # Create a variable that represents an SSH connection to our router. + connection = Netmiko( + username=username, + password=password, + device_type=device_type, + ip=host, + ) + + # Send a command to the router, and get back the output "dictionaried" by textfsm. + textfsm_output = connection.send_command(command_to_run, use_textfsm=True) + + print(f"### This is the TextFSM output from {host}: ###") + print(textfsm_output) + print("\n") # Add extra space between our outputs for each host + + + print(f"### This is the TextFSM output from {host}, but JSON-formatted: ###") + print(json.dumps(textfsm_output, indent=4)) # indent for readability + print("\n") # Add extra space between our outputs for each host diff --git a/1-reading-network-configuration/answers/exercise2/1_create_network_structured_data.py b/1-reading-network-configuration/answers/exercise2/1_create_network_structured_data.py new file mode 100644 index 0000000..d3ac41a --- /dev/null +++ b/1-reading-network-configuration/answers/exercise2/1_create_network_structured_data.py @@ -0,0 +1,399 @@ +# pip install --user textfsm +# pip install --user netmiko +import json +import yaml +import csv +import os +import netmiko +import ipaddress +from copy import deepcopy +from ciscoconfparse import CiscoConfParse + +# Configure logging so it goes to a .log file next to this script. +import logging +this_script_dir = os.path.dirname(os.path.realpath(__file__)) +log_file = f"{this_script_dir}/log/exercise2.log" +logging.basicConfig(filename=log_file, encoding='utf-8', level=logging.DEBUG, filemode="w") + +# Configure a global variables to store things like +# our known BGP key. +# (Don't try this at home) +BGP_MD5_KEY = "foobar" +INPUT_FILENAME = "hosts.yaml" +OUTPUT_FILENAME = "devices.json" + +# The real script +def main(): + + with open("hosts.yaml") as f: + # This creates a list of dictionaries from a YAML file. + # + # This will take the YAML file that looks similar to the following: + # - host: "10.0.0.1" + # device_type: "cisco_xr" + # username: "root" + # password: "password" + # - host: "10.0.0.2 + # {...} + + # And create a dictionary, looking like the following: + # [{"host": "10.0.0.1", "device_type": "cisco_xr", "username": "root", "password": "password"}, {...}] + # + # We can access the IP address of the first host like so: + # first_host_ip = mylist[0]["host"] + + # YAML is convenient, and only a single line of code is required. + hosts = yaml.safe_load(f) + + + + # Now we are in the meat of it. Let's look at each host. + parsed_data = [] + for host in hosts: + + # Create the connection with our host. + connection = netmiko.ConnectHandler(**host) + + try: + # We need the name, ip, platform, version, BGP peers, and interfaces of our host. + # The functions that are called to obtain these are different based on if we are + # on a Cisco or Juniper device. We know ahead of time which hosts are going to + # which OS, so we will look and then collect data based on it. + if host["device_type"] == "cisco_xr": + # Call each helper function from below to collect the data. + data = { + "name": get_cisco_hostname(connection), + "ip": host["host"], + "platform": host["device_type"], + "version": get_cisco_version(connection), + "peers": get_cisco_bgp_peers(connection, md5_key=BGP_MD5_KEY), + "interfaces": get_cisco_interfaces(connection) + } + + elif host["device_type"] == "juniper_junos": + # Call each helper function from below to collect the data. + data = { + "name": get_junos_hostname(connection), + "ip": host["host"], + "platform": host["device_type"], + "version": get_junos_version(connection), + "peers": get_junos_bgp_peers(connection, md5_key=BGP_MD5_KEY), + "interfaces": get_junos_interfaces(connection) + } + + # If the host is neither our Cisco and Juniper OSs, then cause an error. + else: + raise Exception(f"Device type {host['device_type']} not recognized.") + + # We are done with this host, let's add it to the data. + parsed_data.append(data) + print(json.dumps(data, indent=4)) + + # If anything wrong happens that causes an Exception, the script will move to this line. + # It will print out the host that we errored on, and then containue raising the exception. + except Exception: + print(f"Errored on host: {host}!") + raise + + # "finally" means that we will run this line, no matter what. + # We want to make sure we close our SSH connections whether things work, or not, + # otherwise, they will become stale and take up TTYs on the routers. + finally: + connection.disconnect() + + # Put our data into a file as a JSON. + with open(OUTPUT_FILENAME, "w") as f: + json.dump(parsed_data, f, indent=4) + + """ Done! """ + + +##### +# Helper functions +##### + +def get_junos_hostname(connection: netmiko.ConnectHandler): + # Extract the hostname from running "show version | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Run the command, and convert the JSON output into a Python dictionary. + output = json.loads(connection.send_command("show version | display json")) + # Read the dictionary to pull out the hostname value. + hostname = output["software-information"][0]["host-name"][0]["data"] + return hostname + + +def get_junos_version(connection: netmiko.ConnectHandler): + # Extract the version number from "show version | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Run the command, and convert the JSON output into a Python dictionary. + output = json.loads(connection.send_command("show version | display json")) + # Read the dictionary to pull out the version number value. + version = output["software-information"][0]["junos-version"][0]["data"] + return version + + +def get_junos_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + # Extract the BGP peers from running "show bgp neighbor | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Create an empty list to store our data in. + result = [] + + # Run the command, and convert the JSON output into a Python dictionary. + bgp_data = json.loads(connection.send_command("show bgp neighbor | display json")) + + # Read the dictionary to pull out the list of BGP peers. + list_of_peers = bgp_data["bgp-information"][0]["bgp-peer"] + + # Iterate over each peer + for peer in list_of_peers: + # Extract the IP and port number for each BGP peer. + # It will look like "10.10.10.1+12345" + peer_ip_and_port = peer["peer-address"][0]["data"] + + # Manipulate the string and get just the IP. + ip = peer_ip_and_port.split("+")[0] + + # Add it to our "result" list that we will return at the end. + result.append({"remote_address": ip, "md5_key": md5_key}) + + return result + + +def get_junos_interfaces(connection: netmiko.ConnectHandler): + # Create a detailed dictionary of all interfaces and their configuration + # using "show configuration interfaces | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Create an empty list to store our data in. + result = [] + + # Run the command, and convert the JSON output into a Python dictionary. + intf_data = json.loads( + connection.send_command("show configuration interfaces | display json") + ) + + # Drill down into the dictionary to where the interfaces really are. + interfaces = intf_data["configuration"]["interfaces"]["interface"] + for intf in interfaces: + # Now we will look at each interface. The data we are looking at right now looks simiar to: + # + # eth1 { + # description foobar; + # unit 0 { + # family inet { + # address 172.17.1.17/31; + # } + # } + # unit 100 { + # description foo; + # vlan-id 100; + # family inet { + # address 198.51.100.2/24; + # } + # } + # unit 200 { + # description foo; + # vlan-id 200; + # family inet { + # address 192.0.2.2/24; + # } + # } + # + # For each interface, add the name and description (if it exists) to our own result. + # Also add an empty list for subinterfaces, which we will populate next. + data = { + "name": intf["name"], + "description": intf["description"] if "description" in intf.keys() else "", + "sub_ints": [], + } + # Drill down more into the interface and look at its subinterfaces. + for sub_int in intf["unit"]: + # Create the "full name" based off the unit number that we see. + # Ex. "100" becomes "eth1.100" + name = f"{intf['name']}.{sub_int['name']}" + + # Add the description to our subinterface data, if it exists. + description = ( + sub_int["description"] if "description" in sub_int.keys() else "" + ) + # Add the vlan id to our subinterface data, if it exists. + vlan_id = sub_int["vlan-id"] if "vlan-id" in sub_int.keys() else "" + + # Now, extract the IP address + # We will assume there is only a single IPv4 address configured. + addr = sub_int["family"]["inet"]["address"][0]["name"] + + # Use Python's ipaddress module to read our string into a sophisticated + # IPv4_Interface object. This lets us do cool things. + addr = ipaddress.ip_interface(addr) + + # The cool thing we do: It automatically converts our /24 to 255.255.255.0. + # (We won't code the conversions ourself, that's what this is for) + ip, mask = addr.with_netmask.split("/") + + # If the unit is 0, add our collected data to the top-level interface (ex. eth1). + # We do this instead of adding it as "eth1.0" to the subinterfaces. + # This keeps our behavior consistent among different vendors. + if str(name) == "0": + data.update({"ip_address": ip, "subnet_mask": mask, "vlan": vlan_id}) + + # If it isn't unit 0, then add a subinterface to our list. + else: + data["sub_ints"].append( + { + "name": name, + "description": description, + "vlan": vlan_id, + "ip_address": ip, + "subnet_mask": mask, + } + ) + # Add all data about this interface into our result to send later. + # Then, move to the next interface. + result.append(data) + + return result + + +def get_cisco_hostname(connection: netmiko.ConnectHandler): + # Run "show run hostname" and collect the output. + output = connection.send_command("show run hostname") + + # Ex. Turn "hostname cisco1" into "cisco1" and return. + return output.split()[-1] + + +def get_cisco_version(connection: netmiko.ConnectHandler): + # Run "show version | i ^ Version" and collect the output. + output = connection.send_command("show version | i ^ Version") + + # Ex. Turn the outputted " Version : 7.9.1" into "7.9.1" and return. + return output.split()[-1] + + +def get_cisco_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + # Run "show ip bgp summary" and get the IPs of all peers. + command = "show ip bgp summary" + + # Create an empty list to store our data. + result = [] + + # Send our command and get the output. + # Our output will be pre-formatted because are turning TextFSM on. + # TextFSM understands what the output on th router will look like, since + # we are running a command it supports. + bgp_neighbors = connection.send_command(command, use_textfsm=True) + + for peer in bgp_neighbors: + # Add the IP addresses to our data that we're collecting. + try: + result.append({"remote_address": peer["bgp_neigh"], "md5_key": md5_key}) + + # If BGP is not running, the router will print something like, + # "% BGP instance 'default' not active" + # Netmiko sees "% " and knows an error happened. + # Catch this error so we can choose just to log and ignore this device. + except TypeError: + # This 'replace' turns the carriage returns in the raw output into a single-lined string. + # We don't want that in our logs. + flattened_output = bgp_neighbors.replace('\n', '\\n') + logging.info(f"Cannot format output for \"{command}\". BGP may not be running? Raw output:{flattened_output}") + return [] + return result + + +def get_cisco_interfaces(connection: netmiko.ConnectHandler): + # For interface configuration on Cisco devices, we can use the "ciscoconfparse" module, since + # TextFSM doesn't support our command. + # + # We can search and extract blocks of configuration like this, getting only the interfaces + # we care about by using the right CiscoConfParse functions. + # + # interface GigabitEthernet0/0/0/1.100 + # description bar to foo + # ipv4 address 198.51.100.1 255.255.255.0 + # encapsulation dot1q 100 + # ! + # + # After cleaning up the output (like removing extra spaces), we can format like so: + # + # { + # "name": "Gi0/0/0/1", + # "description": "Some customer connects here!", + # "vlan": "100", + # "ip_address": "10.0.0.1", + # "subnet_mask": "255.255.255.0" + # } + + # Create an empty dictionary to store our interfaces as we discover them. + interfaces = {} + + # Create an empty list to store subinterfaces as we discover them, and we'll + # nest them in the appropriate parent interfaces later. + sub_interfaces = [] + + # Get the output for "show run". This will be raw and unformatted. + cisco_config = connection.send_command("show run") + + # Turn this giant singular string of output into a list of lines. + parser = CiscoConfParse(cisco_config.split("\n")) + + # parser.find_objects('^interface .*') will automatically make a list of all + # lines that start with "interface " that we can iterate over. + # It's also nice because it stores that interface's configuration with it. + for intf in parser.find_objects('^interface .*'): + + # Get the name by converting "interface GigabitEthernet0/1" to "GigabitEthernet0/1". + intf_name = intf.text.split()[-1] + + # Find the "description" line, and extract. Ex. Turn "description hello!" into "hello!" + intf_description = intf.re_search_children("^ description ") + if intf_description: + tmp = intf_description[0].text.strip() + intf_description = " ".join(tmp.split()[1:]) + else: + # If description doesn't exist, just use an empty string. + intf_description = "" + + # Extract the vlan id. Ex. turn "encapsulation dot1q 100" into "100". + intf_vlan = intf.re_search_children("^ encapsulation dot1q ") + intf_vlan = intf_vlan[0].text.split()[-1] if intf_vlan else "" + + # Extract the IP address and mask. + # Ex. Turn "ipv4 address 10.10.10.1 255.255.255.0" into two separate stringsm + # one in our 'ip' variable and the other in our 'mask' variable. + raw_ipmask = intf.re_search_children("^ ipv4 address ") + ip, mask = raw_ipmask[0].text.split()[-2] if raw_ipmask else "", "" + + # Take all the interface config we collected and put it into a nicely-formatted dictionary. + data = { + "name": intf_name, + "description": intf_description, + "vlan": intf_vlan, + "ip_address": ip, + "subnet_mask": mask + } + + # If it's a subinterface, put in the 'sub_interfaces' list to store later. + if "." in intf_name: + sub_interfaces.append(data) + # Otherwise, put it in our top-most 'interfaces' dictionary. + else: + data["sub_ints"] = [] + interfaces[intf_name] = data + + # Finally we are done going through our interfaces. + # Lets go back and sort all our subinterfaces into their parents. + for i in sub_interfaces: + parent_intf = i["name"].split(".")[0] + interfaces[parent_intf]["sub_ints"].append(i) + + # Return our interfaces. + return list(interfaces.values()) + +if __name__ == "__main__": + main() + diff --git a/1-reading-network-configuration/answers/exercise2/2_add_customers_to_interfaces.py b/1-reading-network-configuration/answers/exercise2/2_add_customers_to_interfaces.py new file mode 100644 index 0000000..c2504dc --- /dev/null +++ b/1-reading-network-configuration/answers/exercise2/2_add_customers_to_interfaces.py @@ -0,0 +1,109 @@ +import csv +import json + +# Configure a global variables to store certain things. +# (Don't try this at home) +DEVICES_FILENAME = "devices.json" +CUSTOMERS_FILENAME = "customer_interfaces.csv" +OUTPUT_FILENAME = "answer.json" + +def main(): + with open(DEVICES_FILENAME) as f: + # First, let's read in our previous data from JSON to a Python Dictionary. + devices = json.load(f) + if not devices: + raise ValueError(f"File {DEVICES_FILENAME} is empty!") + + + with open(CUSTOMERS_FILENAME) as f: + # Next, we'll read in our customer data. + # + # This creates a list containing dictionaries. + # This will take a line like the following: + # cisco1,Gi0/0/0/1.100,Acme Co. + # + # And add it to the a list, which will look like: + # [{"device": "cisco1", "interface": "Gi0/0/0/1.100", "customer": "Acme Co."}, {...}] + # + # We can access the customer of the first interface like so: + # some_customer = mylist[0]["customer"] + + # Create an empty list where we can store the rows we read. + customer_interfaces = [] + + # The csv.DictReader reader will automatically associate each row + # with the CSV headers, like "customer" or "device". + csv_reader = csv.DictReader(f) + for row in csv_reader: + customer_interfaces.append(row) + + # If we didn't see anything in the customers file, raise an exception. + if not customer_interfaces: + raise ValueError(f"File {CUSTOMERS_FILENAME} is empty!") + + + # Now that we have both our device data and customer data, + # let's blend the data together. + for row in customer_interfaces: + + # First, find the device. + device_dict = find_device(devices, row["device"]) + + # Then, find the interface within that device. + interface_dict = find_interface(device_dict, row["interface"]) + + # Finally, at the customer into the interface we found. + interface_dict["customer"] = row["customer"] + + + # We've now added customer names to each of our interfaces. + # Let's print our new data for good measure, and then store it + # in our new file. + print(json.dumps(devices, indent=4)) + with open(OUTPUT_FILENAME, "w") as f: + json.dump(devices, f, indent=4) + + + """ Done! """ + + +##### +# Helper functions +##### + +def find_device(devices, device_name): + for device in devices: + # If the name matches, the device was found. Return. + if device["name"] == device_name: + return device + + raise ValueError(f"Could not find device {device_name}!") + +def find_interface(device, interface_name): + + is_sub_int = False + if "." in interface_name: + is_sub_int = True + + for intf in device["interfaces"]: + # If we know it is a sub interface, then we know + # we need to go deeper. + if is_sub_int: + for sub_int in intf["sub_ints"]: + if interface_name == sub_int["name"]: + return sub_int + + # If it isn't a sub interface, stay at the top. + else: + if interface_name == intf["name"]: + return intf + + raise ValueError( + f"Could not find interface {interface_name} on device {device}!" + ) + + + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/1-reading-network-configuration/answers/exercise2/answer.json b/1-reading-network-configuration/answers/exercise2/answer.json new file mode 100644 index 0000000..6b77b0b --- /dev/null +++ b/1-reading-network-configuration/answers/exercise2/answer.json @@ -0,0 +1,177 @@ +[ + { + "name": "cisco1", + "ip": "172.16.1.2", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [ + { + "remote_address": "198.51.100.2", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "Loopback1", + "description": "PEER_A_NETWORK", + "vlan": "", + "ip_address": "10.0.1.1", + "subnet_mask": "", + "sub_ints": [], + "customer": "Acme Corporation" + }, + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.1.2", + "subnet_mask": "", + "sub_ints": [], + "customer": "Beta Industries" + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Gamma Enterprises" + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "foobar", + "vlan": "", + "ip_address": "172.17.1.16", + "subnet_mask": "", + "sub_ints": [ + { + "name": "GigabitEthernet0/0/0/1.100", + "description": "bar to foo", + "vlan": "100", + "ip_address": "198.51.100.1", + "subnet_mask": "", + "customer": "Epsilon Electronics" + }, + { + "name": "GigabitEthernet0/0/0/1.200", + "description": "foo to biz", + "vlan": "200", + "ip_address": "192.0.2.1", + "subnet_mask": "", + "customer": "Zeta Zoological" + } + ], + "customer": "Delta Dynamics" + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Eta Enterprises" + } + ] + }, + { + "name": "cisco2", + "ip": "172.16.1.3", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [], + "interfaces": [ + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.1.3", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + } + ] + }, + { + "name": "juniper1", + "ip": "172.16.1.4", + "platform": "juniper_junos", + "version": "23.1R1.8", + "peers": [ + { + "remote_address": "198.51.100.1", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "eth1", + "description": "foobar", + "sub_ints": [ + { + "name": "eth1.0", + "description": "", + "vlan": "", + "ip_address": "172.17.1.17", + "subnet_mask": "255.255.255.254", + "customer": "Theta Technologies" + }, + { + "name": "eth1.100", + "description": "foo", + "vlan": 100, + "ip_address": "198.51.100.2", + "subnet_mask": "255.255.255.0", + "customer": "Iota Innovations" + }, + { + "name": "eth1.200", + "description": "foo", + "vlan": 200, + "ip_address": "192.0.2.2", + "subnet_mask": "255.255.255.0", + "customer": "Kappa Kinetics" + } + ] + }, + { + "name": "eth2", + "description": "", + "sub_ints": [ + { + "name": "eth2.0", + "description": "EXAMPLE_NETWORK", + "vlan": "", + "ip_address": "10.0.2.1", + "subnet_mask": "255.255.255.0", + "customer": "Lambda Labs" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/1-reading-network-configuration/blah.py b/1-reading-network-configuration/blah.py new file mode 100644 index 0000000..7ae77fb --- /dev/null +++ b/1-reading-network-configuration/blah.py @@ -0,0 +1,153 @@ +# pip install --user textfsm +# pip install --user netmiko +import json +import netmiko +import ipaddress + +bgp_md5_key = "foobar" +hosts = [ + { + "host": "172.16.3.2", + "device_type": "cisco_xr", + "username": "clab", + "password": "clab@123", + }, + { + "host": "172.16.3.3", + "device_type": "cisco_xr", + "username": "clab", + "password": "clab@123", + }, + { + "host": "172.16.3.4", + "device_type": "juniper_junos", + "username": "clab", + "password": "clab123", + }, +] + + +def main(): + result = {"hosts": []} + + for host in hosts: + parsed_data = {} + + connection = netmiko.ConnectHandler(**host) + + try: + if host["device_type"] == "cisco_xr": + print(get_cisco_hostname(connection)) + print(get_cisco_version(connection)) + print(get_cisco_bgp_peers(connection, md5_key=bgp_md5_key)) + print(get_cisco_interfaces(connection)) + + elif host["device_type"] == "juniper_junos": + print(get_junos_hostname(connection)) + print(get_junos_version(connection)) + print(get_junos_bgp_peers(connection, md5_key=bgp_md5_key)) + print(get_junos_interfaces(connection)) + + else: + raise Exception(f"Device type {host['device_type']} not recognized.") + + except Exception: + print(f"Errored on host: {host}!") + raise + finally: + connection.disconnect() + + +def get_junos_hostname(connection: netmiko.ConnectHandler): + output = json.loads(connection.send_command("show version | display json")) + hostname = output["software-information"][0]["host-name"][0]["data"] + return hostname + + +def get_junos_version(connection: netmiko.ConnectHandler): + output = json.loads(connection.send_command("show version | display json")) + version = output["software-information"][0]["junos-version"][0]["data"] + return version + + +def get_junos_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + result = [] + bgp_data = json.loads(connection.send_command("show bgp neighbor | display json")) + + list_of_peers = bgp_data["bgp-information"][0]["bgp-peer"] + + for peer in list_of_peers: + peer_ip_and_port = peer["peer-address"][0]["data"] + ip = peer_ip_and_port.split("+")[0] + result.append({"remote_address": ip, "md5_key": md5_key}) + + return result + + +def get_junos_interfaces(connection: netmiko.ConnectHandler): + result = [] + intf_data = json.loads( + connection.send_command("show configuration interfaces | display json") + ) + interfaces = intf_data["configuration"]["interfaces"]["interface"] + for intf in interfaces: + data = { + "name": intf["name"], + "description": intf["description"] if "description" in intf.keys() else "", + "sub_ints": [], + } + for sub_int in intf["unit"]: + name = sub_int["name"] + description = ( + sub_int["description"] if "description" in sub_int.keys() else "" + ) + vlan_id = sub_int["vlan-id"] if "vlan-id" in sub_int.keys() else "" + + # We will assume there is only a single IPv4 address configured. + addr = sub_int["family"]["inet"]["address"][0]["name"] + addr = ipaddress.ip_interface(addr) + ip, mask = addr.with_netmask.split("/") + + if str(name) == "0": + data.update({"ip_address": ip, "subnet_mask": mask, "vlan": vlan_id}) + else: + data["sub_ints"].append( + { + "name": name, + "description": description, + "vlan": vlan_id, + "ip_address": ip, + "subnet_mask": mask, + } + ) + result.append(data) + + return result + + +def get_cisco_hostname(connection: netmiko.ConnectHandler): + output = connection.send_command("show run hostname").split()[-1] + return output + + +def get_cisco_version(connection: netmiko.ConnectHandler): + output = connection.send_command("show version | i ^ Version") + return output.split()[-1] + + +def get_cisco_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + result = [] + bgp_neighbors = connection.send_command("show ip bgp summary", use_textfsm=True) + for peer in bgp_neighbors: + result.append({"remote_address": peer["bgp_neigh"], "md5_key": md5_key}) + return result + + +def get_cisco_interfaces(connection: netmiko.ConnectHandler): + # TODO + intf_data = connection.send_command("show interfaces", use_textfsm=True) + return intf_data + + +if __name__ == "__main__": + main() diff --git a/1-reading-network-configuration/customer_ports.json b/1-reading-network-configuration/customer_ports.json new file mode 100644 index 0000000..e05aa3c --- /dev/null +++ b/1-reading-network-configuration/customer_ports.json @@ -0,0 +1,17 @@ +{ + "cisco1": { + "Loopback1": "Acme Co.", + "MgmtEth0/RP0/CPU0/0": "Globex Corp.", + "GigabitEthernet0/0/0/0": "Initech", + "GigabitEthernet0/0/0/1": "Hooli", + "GigabitEthernet0/0/0/1.100": "Wonka Industries", + "GigabitEthernet0/0/0/1.200": "Stark Industries", + "GigabitEthernet0/0/0/2": "Acme Co." + }, + "juniper1": { + "eth1.0": "Monsters, Inc.", + "eth1.100": "Umbrella Corporation", + "eth1.200": "Wayne Enterprises", + "eth2.0": "Dunder Mifflin" + } +} \ No newline at end of file diff --git a/1-reading-network-configuration/exercise1/1_netmiko_show_interfaces_raw.py b/1-reading-network-configuration/exercise1/1_netmiko_show_interfaces_raw.py new file mode 100644 index 0000000..6563519 --- /dev/null +++ b/1-reading-network-configuration/exercise1/1_netmiko_show_interfaces_raw.py @@ -0,0 +1,29 @@ +# pip install --user netmiko +from netmiko import Netmiko + +username = "fill me in!" +password = "fill me in!" +device_type = "fill me in!" +hosts = ["x.x.x.x", "y.y.y.y"] +command_to_run = "show int brief" + +for host in hosts: + # Create a variable that represents an SSH connection to our router. + connection = Netmiko( + username=username, + password=password, + device_type=device_type, + ip=host + ) + + # Send a command to the router, and get back the raw output + raw_output = connection.send_command(command_to_run) + + # The "really raw" output has '\n' characters appear instead of a real carriage return. + # Converting them into carriage returns will make it a little more readable for this demo. + raw_output = raw_output.replace("\\n", "\n") + + print( + f"### This is the raw output from {host}, without any parsing: ###\n", + raw_output + "\n" + ) diff --git a/1-reading-network-configuration/exercise1/2_netmiko_show_interfaces_textfsm.py b/1-reading-network-configuration/exercise1/2_netmiko_show_interfaces_textfsm.py new file mode 100644 index 0000000..973ecf0 --- /dev/null +++ b/1-reading-network-configuration/exercise1/2_netmiko_show_interfaces_textfsm.py @@ -0,0 +1,32 @@ +# pip install --user textfsm +# pip install --user netmiko +import json +from netmiko import Netmiko +from pprint import pprint + +username = "fill me in!" +password = "fill me in!" +device_type = "fill me in!" +hosts = ["x.x.x.x", "y.y.y.y"] +command_to_run = "show int brief" + +for host in hosts: + # Create a variable that represents an SSH connection to our router. + connection = Netmiko( + username=username, + password=password, + device_type=device_type, + ip=host, + ) + + # Send a command to the router, and get back the output "dictionaried" by textfsm. + textfsm_output = connection.send_command(command_to_run, use_textfsm=True) + + print(f"### This is the TextFSM output from {host}: ###") + print(textfsm_output) + print("\n") # Add extra space between our outputs for each host + + + print(f"### This is the TextFSM output from {host}, but JSON-formatted: ###") + print(json.dumps(textfsm_output, indent=4)) # indent for readability + print("\n") # Add extra space between our outputs for each host diff --git a/1-reading-network-configuration/exercise2/1_create_network_structured_data.py b/1-reading-network-configuration/exercise2/1_create_network_structured_data.py new file mode 100644 index 0000000..ee284ec --- /dev/null +++ b/1-reading-network-configuration/exercise2/1_create_network_structured_data.py @@ -0,0 +1,408 @@ +# pip install --user pyyaml # TODO +# pip install --user textfsm # TODO +# pip install --user netmiko # TODO +# pip install --user ciscoconfparse # TODO +import json +import yaml +import csv +import os +import netmiko +import ipaddress +from copy import deepcopy +from ciscoconfparse import CiscoConfParse + +# Configure logging so it goes to a .log file next to this script. +import logging +this_script_dir = os.path.dirname(os.path.realpath(__file__)) +log_file = f"{this_script_dir}/log/exercise2.log" +logging.basicConfig(filename=log_file, encoding='utf-8', level=logging.DEBUG, filemode="w") + +# Configure a global variables to store things like +# our known BGP key. +# (Don't try this at home) +BGP_MD5_KEY = "foobar" +INPUT_FILENAME = "hosts.yaml" +OUTPUT_FILENAME = "devices.json" + +# The real script +def main(): + + with open(INPUT_FILENAME) as f: + # This creates a list of dictionaries from a YAML file. + # + # This will take the YAML file that looks similar to the following: + # - host: "10.0.0.1" + # device_type: "cisco_xr" + # username: "root" + # password: "password" + # - host: "10.0.0.2 + # {...} + + # And create a dictionary, looking like the following: + # [{"host": "10.0.0.1", "device_type": "cisco_xr", "username": "root", "password": "password"}, {...}] + # + # We can access the IP address of the first host like so: + # first_host_ip = mylist[0]["host"] + + # YAML is convenient, and only a single line of code is required. + hosts = yaml.safe_load(f) + + + + # Now we are in the meat of it. Let's look at each host. + parsed_data = [] + for host in hosts: + + # Create the Netmiko connection with our host. + # Under the hood, "**host" looks like this: + # { + # host: "172.16.1.2" + # device_type: "cisco_xr" + # username: "clab" + # password: "clab@123" + # } + connection = netmiko.ConnectHandler(**host) + + try: + # We need the name, ip, platform, version, BGP peers, and interfaces of our host. + # The functions that are called to obtain these are different based on if we are + # on a Cisco or Juniper device. We know ahead of time which hosts are going to + # which OS, so we will look and then collect data based on it. + if host["device_type"] == "fill me in!": # TODO + # Call each helper function from below to collect the data. + data = { + "name": get_cisco_hostname(connection), + "ip": host["host"], + "platform": host["device_type"], + "version": get_cisco_version(connection), + "peers": get_cisco_bgp_peers(connection, md5_key=BGP_MD5_KEY), + "interfaces": get_cisco_interfaces(connection) + } + + elif host["device_type"] == "fill me in!": # TODO + # Call each helper function from below to collect the data. + data = { + "name": get_junos_hostname(connection), + "ip": host["host"], + "platform": host["device_type"], + "version": get_junos_version(connection), + "peers": get_junos_bgp_peers(connection, md5_key=BGP_MD5_KEY), + "interfaces": get_junos_interfaces(connection) + } + + # If the host is neither our Cisco nor Juniper OSs, then cause an error. + else: + raise Exception(f"Device type {host['device_type']} not recognized.") + + # We are done with this host, let's add it to the data. + parsed_data.append(data) + print(json.dumps(data, indent=4)) + + # If anything wrong happens that causes an Exception, the script will move to this line. + # It will print out the host that we errored on, and then containue raising the exception. + except Exception: + print(f"Errored on host: {host}!") + raise + + # "finally" means that we will run this line, no matter what. + # We want to make sure we close our SSH connections whether things work, or not, + # otherwise, they will become stale and take up TTYs on the routers. + finally: + connection.disconnect() + + # Put our data into a file as a JSON. + with open(OUTPUT_FILENAME, "w") as f: + json.dump(parsed_data, f, indent=4) + + """ Done! """ + + +##### +# Helper functions +##### + +def get_junos_hostname(connection: netmiko.ConnectHandler): + # Extract the hostname from running "show version | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Run the command, and convert the JSON output into a Python dictionary. + output = json.loads(connection.send_command("show version | display json")) + # Read the dictionary to pull out the hostname value. + hostname = output["software-information"][0]["host-name"][0]["data"] + return hostname + + +def get_junos_version(connection: netmiko.ConnectHandler): + # Extract the version number from "show version | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Run the command, and convert the JSON output into a Python dictionary. + output = json.loads(connection.send_command("show version | display json")) + # Read the dictionary to pull out the version number value. + version = output["software-information"][0]["junos-version"][0]["data"] + return version + + +def get_junos_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + # Extract the BGP peers from running "show bgp neighbor | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Create an empty list to store our data in. + result = [] + + # Run the command, and convert the JSON output into a Python dictionary. + bgp_data = json.loads(connection.send_command("show bgp neighbor | display json")) + + # Read the dictionary to pull out the list of BGP peers. + list_of_peers = bgp_data["bgp-information"][0]["bgp-peer"] + + # Iterate over each peer + for peer in list_of_peers: + # Extract the IP and port number for each BGP peer. + # It will look like "10.10.10.1+12345" + peer_ip_and_port = peer["peer-address"][0]["data"] + + # Manipulate the string and get just the IP. + ip = peer_ip_and_port.split("+")[0] + + # Add it to our "result" list that we will return at the end. + result.append({"remote_address": ip, "md5_key": md5_key}) + + return result + + +def get_junos_interfaces(connection: netmiko.ConnectHandler): + # Create a detailed dictionary of all interfaces and their configuration + # using "show configuration interfaces | display json" + # Juniper makes this easy since it will already be in JSON format. + + # Create an empty list to store our data in. + result = [] + + # Run the command, and convert the JSON output into a Python dictionary. + intf_data = json.loads( + connection.send_command("show configuration interfaces | display json") + ) + + # Drill down into the dictionary to where the interfaces really are. + interfaces = intf_data["configuration"]["interfaces"]["interface"] + for intf in interfaces: + # Now we will look at each interface. The data we are looking at right now looks simiar to: + # + # eth1 { + # description foobar; + # unit 0 { + # family inet { + # address 172.17.1.17/31; + # } + # } + # unit 100 { + # description foo; + # vlan-id 100; + # family inet { + # address 198.51.100.2/24; + # } + # } + # unit 200 { + # description foo; + # vlan-id 200; + # family inet { + # address 192.0.2.2/24; + # } + # } + # + # For each interface, add the name and description (if it exists) to our own result. + # Also add an empty list for subinterfaces, which we will populate next. + data = { + "name": intf["name"], + "description": intf["description"] if "description" in intf.keys() else "", + "sub_ints": [], + } + # Drill down more into the interface and look at its subinterfaces. + for sub_int in intf["unit"]: + # Create the "full name" based off the unit number that we see. + # Ex. "100" becomes "eth1.100" + name = f"{intf['name']}.{sub_int['name']}" + + # Add the description to our subinterface data, if it exists. + description = ( + sub_int["description"] if "description" in sub_int.keys() else "" + ) + # Add the vlan id to our subinterface data, if it exists. + vlan_id = sub_int["vlan-id"] if "vlan-id" in sub_int.keys() else "" + + # Now, extract the IP address + # We will assume there is only a single IPv4 address configured. + addr = sub_int["family"]["inet"]["address"][0]["name"] + + # Use Python's ipaddress module to read our string into a sophisticated + # IPv4_Interface object. This lets us do cool things. + addr = ipaddress.ip_interface(addr) + + # The cool thing we do: It automatically converts our /24 to 255.255.255.0. + # (We won't code the conversions ourself, that's what this is for) + ip, mask = addr.with_netmask.split("/") + + # If the unit is 0, add our collected data to the top-level interface (ex. eth1). + # We do this instead of adding it as "eth1.0" to the subinterfaces. + # This keeps our behavior consistent among different vendors. + if str(name) == "0": + data.update({"ip_address": ip, "subnet_mask": mask, "vlan": vlan_id}) + + # If it isn't unit 0, then add a subinterface to our list. + else: + data["sub_ints"].append( + { + "name": name, + "description": description, + "vlan": vlan_id, + "ip_address": ip, + "subnet_mask": mask, + } + ) + # Add all data about this interface into our result to send later. + # Then, move to the next interface. + result.append(data) + + return result + + +def get_cisco_hostname(connection: netmiko.ConnectHandler): + # Run "show run hostname" and collect the output. + output = connection.send_command("show run hostname") + + # Ex. Turn "hostname cisco1" into "cisco1" and return. + return output.split()[-1] + + +def get_cisco_version(connection: netmiko.ConnectHandler): + # Run "show version | i ^ Version" and collect the output. + output = connection.send_command("show version | i ^ Version") + + # Ex. Turn the outputted " Version : 7.9.1" into "7.9.1" and return. + return output.split()[-1] + + +def get_cisco_bgp_peers(connection: netmiko.ConnectHandler, md5_key=""): + # Run "show ip bgp summary" and get the IPs of all peers. + command = "show ip bgp summary" + + # Create an empty list to store our data. + result = [] + + # Send our command and get the output. + # Our output will be pre-formatted because are turning TextFSM on. + # TextFSM understands what the output on th router will look like, since + # we are running a command it supports. + bgp_neighbors = connection.send_command(command, use_textfsm=True) + + for peer in bgp_neighbors: + # Add the IP addresses to our data that we're collecting. + try: + result.append({"remote_address": peer["bgp_neigh"], "md5_key": md5_key}) + + # If BGP is not running, the router will print something like, + # "% BGP instance 'default' not active" + # Netmiko sees "% " and knows an error happened. + # Catch this error so we can choose just to log and ignore this device. + except TypeError: + # This 'replace' turns the carriage returns in the raw output into a single-lined string. + # We don't want that in our logs. + flattened_output = bgp_neighbors.replace('\n', '\\n') + logging.info(f"Cannot format output for \"{command}\". BGP may not be running? Raw output:{flattened_output}") + return [] + return result + + +def get_cisco_interfaces(connection: netmiko.ConnectHandler): + # For interface configuration on Cisco devices, we can use the "ciscoconfparse" module, since + # TextFSM doesn't support our command. + # + # We can search and extract blocks of configuration like this, getting only the interfaces + # we care about by using the right CiscoConfParse functions. + # + # interface GigabitEthernet0/0/0/1.100 + # description bar to foo + # ipv4 address 198.51.100.1 255.255.255.0 + # encapsulation dot1q 100 + # ! + # + # After cleaning up the output (like removing extra spaces), we can format like so: + # + # { + # "name": "Gi0/0/0/1", + # "description": "Some customer connects here!", + # "vlan": "100", + # "ip_address": "10.0.0.1", + # "subnet_mask": "255.255.255.0" + # } + + # Create an empty dictionary to store our interfaces as we discover them. + interfaces = {} + + # Create an empty list to store subinterfaces as we discover them, and we'll + # nest them in the appropriate parent interfaces later. + sub_interfaces = [] + + # Get the output for "show run". This will be raw and unformatted. + cisco_config = connection.send_command("show run") + + # Turn this giant singular string of output into a list of lines. + parser = CiscoConfParse(cisco_config.split("\n")) + + # parser.find_objects('^interface .*') will automatically make a list of all + # lines that start with "interface " that we can iterate over. + # It's also nice because it stores that interface's configuration with it. + for intf in parser.find_objects('^interface .*'): + + # Get the name by converting "interface GigabitEthernet0/1" to "GigabitEthernet0/1". + intf_name = intf.text.split()[-1] + + # Find the "description" line, and extract. Ex. Turn "description hello!" into "hello!" + intf_description = intf.re_search_children("^ description ") + if intf_description: + tmp = intf_description[0].text.strip() + intf_description = " ".join(tmp.split()[1:]) + else: + # If description doesn't exist, just use an empty string. + intf_description = "" + + # Extract the vlan id. Ex. turn "encapsulation dot1q 100" into "100". + intf_vlan = intf.re_search_children("^ encapsulation dot1q ") + intf_vlan = intf_vlan[0].text.split()[-1] if intf_vlan else "" + + # Extract the IP address and mask. + # Ex. Turn "ipv4 address 10.10.10.1 255.255.255.0" into two separate stringsm + # one in our 'ip' variable and the other in our 'mask' variable. + raw_ipmask = intf.re_search_children("^ ipv4 address ") + ip, mask = raw_ipmask[0].text.split()[-2] if raw_ipmask else "", "" + + # Take all the interface config we collected and put it into a nicely-formatted dictionary. + data = { + "name": intf_name, + "description": intf_description, + "vlan": intf_vlan, + "ip_address": ip, + "subnet_mask": mask + } + + # If it's a subinterface, put in the 'sub_interfaces' list to store later. + if "." in intf_name: + sub_interfaces.append(data) + # Otherwise, put it in our top-most 'interfaces' dictionary. + else: + data["sub_ints"] = [] + interfaces[intf_name] = data + + # Finally we are done going through our interfaces. + # Lets go back and sort all our subinterfaces into their parents. + for i in sub_interfaces: + parent_intf = i["name"].split(".")[0] + interfaces[parent_intf]["sub_ints"].append(i) + + # Return our interfaces. + return list(interfaces.values()) + +if __name__ == "__main__": + main() + diff --git a/1-reading-network-configuration/exercise2/2_add_customers_to_interfaces.py b/1-reading-network-configuration/exercise2/2_add_customers_to_interfaces.py new file mode 100644 index 0000000..c2504dc --- /dev/null +++ b/1-reading-network-configuration/exercise2/2_add_customers_to_interfaces.py @@ -0,0 +1,109 @@ +import csv +import json + +# Configure a global variables to store certain things. +# (Don't try this at home) +DEVICES_FILENAME = "devices.json" +CUSTOMERS_FILENAME = "customer_interfaces.csv" +OUTPUT_FILENAME = "answer.json" + +def main(): + with open(DEVICES_FILENAME) as f: + # First, let's read in our previous data from JSON to a Python Dictionary. + devices = json.load(f) + if not devices: + raise ValueError(f"File {DEVICES_FILENAME} is empty!") + + + with open(CUSTOMERS_FILENAME) as f: + # Next, we'll read in our customer data. + # + # This creates a list containing dictionaries. + # This will take a line like the following: + # cisco1,Gi0/0/0/1.100,Acme Co. + # + # And add it to the a list, which will look like: + # [{"device": "cisco1", "interface": "Gi0/0/0/1.100", "customer": "Acme Co."}, {...}] + # + # We can access the customer of the first interface like so: + # some_customer = mylist[0]["customer"] + + # Create an empty list where we can store the rows we read. + customer_interfaces = [] + + # The csv.DictReader reader will automatically associate each row + # with the CSV headers, like "customer" or "device". + csv_reader = csv.DictReader(f) + for row in csv_reader: + customer_interfaces.append(row) + + # If we didn't see anything in the customers file, raise an exception. + if not customer_interfaces: + raise ValueError(f"File {CUSTOMERS_FILENAME} is empty!") + + + # Now that we have both our device data and customer data, + # let's blend the data together. + for row in customer_interfaces: + + # First, find the device. + device_dict = find_device(devices, row["device"]) + + # Then, find the interface within that device. + interface_dict = find_interface(device_dict, row["interface"]) + + # Finally, at the customer into the interface we found. + interface_dict["customer"] = row["customer"] + + + # We've now added customer names to each of our interfaces. + # Let's print our new data for good measure, and then store it + # in our new file. + print(json.dumps(devices, indent=4)) + with open(OUTPUT_FILENAME, "w") as f: + json.dump(devices, f, indent=4) + + + """ Done! """ + + +##### +# Helper functions +##### + +def find_device(devices, device_name): + for device in devices: + # If the name matches, the device was found. Return. + if device["name"] == device_name: + return device + + raise ValueError(f"Could not find device {device_name}!") + +def find_interface(device, interface_name): + + is_sub_int = False + if "." in interface_name: + is_sub_int = True + + for intf in device["interfaces"]: + # If we know it is a sub interface, then we know + # we need to go deeper. + if is_sub_int: + for sub_int in intf["sub_ints"]: + if interface_name == sub_int["name"]: + return sub_int + + # If it isn't a sub interface, stay at the top. + else: + if interface_name == intf["name"]: + return intf + + raise ValueError( + f"Could not find interface {interface_name} on device {device}!" + ) + + + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/1-reading-network-configuration/exercise2/README.md b/1-reading-network-configuration/exercise2/README.md new file mode 100644 index 0000000..ea958e6 --- /dev/null +++ b/1-reading-network-configuration/exercise2/README.md @@ -0,0 +1,205 @@ +# Exercise 2: Structured Data and Scraping the Network + + +### How to Complete + +To complete this exercise, all TODOs must be completed in both scripts and one input YAML: +- 1_create_network_structured_data.py +- 2_add_customers_to_interfaces.py +- hosts.yaml + +If done successfully, both scripts can be run in sequential order. They will output results as they run. + +``` +From the 2023-workshop-automation folder: + +cd 1-reading-network-configuration/exercise2 +python 1_create_network_structured_data.py +python 2_add_customers_to_interfaces.py +``` + + +### Answer + +The newly outputted `answers.json` will match the following, where {{x}} is your lab number: + +``` +[ + { + "name": "cisco1", + "ip": "172.16.{{x}}.2", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [ + { + "remote_address": "198.51.100.2", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "Loopback1", + "description": "PEER_A_NETWORK", + "vlan": "", + "ip_address": "10.0.1.1", + "subnet_mask": "", + "sub_ints": [], + "customer": "Acme Corporation" + }, + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.{{x}}.2", + "subnet_mask": "", + "sub_ints": [], + "customer": "Beta Industries" + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Gamma Enterprises" + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "foobar", + "vlan": "", + "ip_address": "172.17.1.16", + "subnet_mask": "", + "sub_ints": [ + { + "name": "GigabitEthernet0/0/0/1.100", + "description": "bar to foo", + "vlan": "100", + "ip_address": "198.51.100.1", + "subnet_mask": "", + "customer": "Epsilon Electronics" + }, + { + "name": "GigabitEthernet0/0/0/1.200", + "description": "foo to biz", + "vlan": "200", + "ip_address": "192.0.2.1", + "subnet_mask": "", + "customer": "Zeta Zoological" + } + ], + "customer": "Delta Dynamics" + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Eta Enterprises" + } + ] + }, + { + "name": "cisco2", + "ip": "172.16.{{x}}.3", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [], + "interfaces": [ + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.{{x}}.3", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + } + ] + }, + { + "name": "juniper1", + "ip": "172.16.{{x}}.4", + "platform": "juniper_junos", + "version": "23.1R1.8", + "peers": [ + { + "remote_address": "198.51.100.1", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "eth1", + "description": "foobar", + "sub_ints": [ + { + "name": "eth1.0", + "description": "", + "vlan": "", + "ip_address": "172.17.1.17", + "subnet_mask": "255.255.255.254", + "customer": "Theta Technologies" + }, + { + "name": "eth1.100", + "description": "foo", + "vlan": 100, + "ip_address": "198.51.100.2", + "subnet_mask": "255.255.255.0", + "customer": "Iota Innovations" + }, + { + "name": "eth1.200", + "description": "foo", + "vlan": 200, + "ip_address": "192.0.2.2", + "subnet_mask": "255.255.255.0", + "customer": "Kappa Kinetics" + } + ] + }, + { + "name": "eth2", + "description": "", + "sub_ints": [ + { + "name": "eth2.0", + "description": "EXAMPLE_NETWORK", + "vlan": "", + "ip_address": "10.0.2.1", + "subnet_mask": "255.255.255.0", + "customer": "Lambda Labs" + } + ] + } + ] + } +] +``` + diff --git a/1-reading-network-configuration/exercise2/customer_interfaces.csv b/1-reading-network-configuration/exercise2/customer_interfaces.csv new file mode 100644 index 0000000..6dfea76 --- /dev/null +++ b/1-reading-network-configuration/exercise2/customer_interfaces.csv @@ -0,0 +1,12 @@ +device,interface,customer +cisco1,Loopback1,Acme Corporation +cisco1,MgmtEth0/RP0/CPU0/0,Beta Industries +cisco1,GigabitEthernet0/0/0/0,Gamma Enterprises +cisco1,GigabitEthernet0/0/0/1,Delta Dynamics +cisco1,GigabitEthernet0/0/0/1.100,Epsilon Electronics +cisco1,GigabitEthernet0/0/0/1.200,Zeta Zoological +cisco1,GigabitEthernet0/0/0/2,Eta Enterprises +juniper1,eth1.0,Theta Technologies +juniper1,eth1.100,Iota Innovations +juniper1,eth1.200,Kappa Kinetics +juniper1,eth2.0,Lambda Labs diff --git a/1-reading-network-configuration/exercise2/hosts.yaml b/1-reading-network-configuration/exercise2/hosts.yaml new file mode 100644 index 0000000..cf69b15 --- /dev/null +++ b/1-reading-network-configuration/exercise2/hosts.yaml @@ -0,0 +1,12 @@ +- host: "" + device_type: "cisco_xr" + username: "clab" + password: "clab@123" +- host: "" + device_type: "cisco_xr" + username: "clab" + password: "clab@123" +- host: "" + device_type: "juniper_junos" + username: "clab" + password: "clab123" diff --git a/1-reading-network-configuration/exercise2/log/.gitkeep b/1-reading-network-configuration/exercise2/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/1-reading-network-configuration/my_hosts.yaml b/1-reading-network-configuration/my_hosts.yaml new file mode 100644 index 0000000..be43f58 --- /dev/null +++ b/1-reading-network-configuration/my_hosts.yaml @@ -0,0 +1,13 @@ +hosts: + - host: "172.16.3.2" + device_type: "cisco_xr" + username: "clab" + password: "clab@123" + - host": 172.16.3.3" + device_type: "cisco_xr" + username": "clab" + password: "clab@123" + - host: "172.16.3.4" + device_type": "juniper_junos" + username: "clab" + password: "clab123" diff --git a/2-transforming-configuration/.gitkeep b/2-transforming-configuration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/2-transforming-configuration/ansible.cfg b/2-transforming-configuration/ansible.cfg new file mode 100644 index 0000000..86cfd2f --- /dev/null +++ b/2-transforming-configuration/ansible.cfg @@ -0,0 +1,2 @@ +[defaults] +host_key_checking = False \ No newline at end of file diff --git a/2-transforming-configuration/cisco.j2 b/2-transforming-configuration/cisco.j2 new file mode 100644 index 0000000..9656d95 --- /dev/null +++ b/2-transforming-configuration/cisco.j2 @@ -0,0 +1,11 @@ +{% for int in interfaces %} +interface {{ int.name }} + description "Customer: {{ int.customer | default("NONE") }} Physical connection" +! +{% for subint in int.sub_ints %} +interface {{ int.name }}.{{ subint.vlan }} + description "Customer: {{ subint.customer | default("NONE") }} VLAN: {{ subint.vlan }}" +! +{% endfor %} +{% endfor %} + \ No newline at end of file diff --git a/2-transforming-configuration/cisco2.j2 b/2-transforming-configuration/cisco2.j2 new file mode 100644 index 0000000..6031503 --- /dev/null +++ b/2-transforming-configuration/cisco2.j2 @@ -0,0 +1,12 @@ +[ +{% for int in interfaces %} + {'name': '{{ int.name }}', + 'description': 'Customer: {{ int.customer | default("NONE") }} Physical connection'}, + +{% for subint in int.sub_ints %} + {'name': '{{ subint.name }}', + 'description': 'Customer: {{ subint.customer | default("NONE") }} VLAN: {{ subint.vlan }}' }, + +{% endfor %} +{% endfor %} +] diff --git a/2-transforming-configuration/hosts.yaml b/2-transforming-configuration/hosts.yaml new file mode 100644 index 0000000..3e26057 --- /dev/null +++ b/2-transforming-configuration/hosts.yaml @@ -0,0 +1,22 @@ +--- +all: + children: + cisco: + hosts: + cisco_1: + ansible_host: 172.16.20.2 + device_type: "IOSXR" + cisco_2: + ansible_host: 172.16.20.3 + device_type: "IOSXR" + vars: + ansible_user: clab + ansible_ssh_pass: clab@123 + junos: + vars: + ansible_user: clab + ansible_ssh_pass: clab123 + hosts: + juniper: + ansible_host: 172.16.20.4 + device_type: "JUNOS" diff --git a/2-transforming-configuration/inventory.yaml b/2-transforming-configuration/inventory.yaml new file mode 100644 index 0000000..17ff26c --- /dev/null +++ b/2-transforming-configuration/inventory.yaml @@ -0,0 +1,23 @@ +all: + children: + iosxr: + hosts: + cisco1: + ansible_host: 172.16.1.2 + device_type: IOSXR + cisco2: + ansible_host: 172.16.1.3 + device_type: IOSXR + vars: + ansible_connection: ansible.netcommon.network_cli + ansible_network_os: iosxr + ansible_ssh_pass: clab@123 + junos: + hosts: + juniper1: + ansible_host: 172.16.1.4 + device_type: JUNOS + vars: + ansible_ssh_pass: clab123 + vars: + ansible_user: clab diff --git a/2-transforming-configuration/inventory_generator.orig.py b/2-transforming-configuration/inventory_generator.orig.py new file mode 100644 index 0000000..d26f505 --- /dev/null +++ b/2-transforming-configuration/inventory_generator.orig.py @@ -0,0 +1,23 @@ +#!/usr/bin/python + +import json +import yaml + +f = open('output.json') + +data = json.load(f) + +f.close() + +inventory = {'all': {'vars': {'ansible_user': 'clab'}, 'children': {'junos': {'hosts': {}}, 'iosxr': {'hosts':{}}}}} + +for host in data: + if(host['platform'] == 'juniper_junos'): + inventory['all']['children']['junos']['hosts'][host['name']] = {'device_type': 'JUNOS' } + elif(host['platform'] == 'cisco_xr'): + inventory['all']['children']['iosxr']['hosts'][host['name']] = {'device_type': 'IOSXR'} + else: + inventory['all']['hosts'][host['name']] = {} + +with open('inventory.yaml', 'w') as file: + yaml.dump(inventory, file) diff --git a/2-transforming-configuration/inventory_generator.py b/2-transforming-configuration/inventory_generator.py new file mode 100644 index 0000000..f77b36a --- /dev/null +++ b/2-transforming-configuration/inventory_generator.py @@ -0,0 +1,26 @@ +#!/usr/bin/python + +import json +import yaml + +f = open('output.json') + +data = json.load(f) + +f.close() + +inventory = {'all': {'vars': {'ansible_user': 'clab'}, 'children': {'junos': {'hosts': {}, 'vars': {'ansible_ssh_pass': 'clab123'}}, + 'iosxr': {'hosts':{}, 'vars': {'ansible_network_os': 'iosxr', + 'ansible_ssh_pass': 'clab@123', + 'ansible_connection': 'ansible.netcommon.network_cli'}}}}} + +for host in data: + if(host['platform'] == 'juniper_junos'): + inventory['all']['children']['junos']['hosts'][host['name']] = {'ansible_host': host['ip'], 'device_type': 'JUNOS' } + elif(host['platform'] == 'cisco_xr'): + inventory['all']['children']['iosxr']['hosts'][host['name']] = {'ansible_host': host['ip'], 'device_type': 'IOSXR'} + else: + inventory['all']['hosts'][host['name']] = {'ansible_host': host['ip']} + +with open('inventory.yaml', 'w') as file: + yaml.dump(inventory, file) diff --git a/2-transforming-configuration/juniper.j2 b/2-transforming-configuration/juniper.j2 new file mode 100644 index 0000000..3d9ed88 --- /dev/null +++ b/2-transforming-configuration/juniper.j2 @@ -0,0 +1,10 @@ +{% for int in interfaces %} +set interfaces {{ int.name }} description "Customer: {{ int.customer | default('NONE')}} Physical connection" +{% for subint in int.sub_ints %} +{% if subint.vlan == "" %} +set interfaces {{ int.name }} unit 0 description "Customer: {{ subint.customer | default('NONE') }} VLAN: untagged" +{% else %} +set interfaces {{ int.name }} unit {{ subint.vlan }} description "Customer: {{ subint.customer | default('NONE') }} VLAN: {{ subint.vlan }}" +{% endif %} +{% endfor %} +{% endfor %} diff --git a/2-transforming-configuration/normalize_descriptions.yaml b/2-transforming-configuration/normalize_descriptions.yaml new file mode 100644 index 0000000..531919a --- /dev/null +++ b/2-transforming-configuration/normalize_descriptions.yaml @@ -0,0 +1,46 @@ +--- +- name: Normalize interface descriptions + hosts: all + gather_facts: no + + roles: + - Juniper.junos + collections: + - cisco.ios + + vars: + hosts: "{{ lookup('file', 'output.json') | from_json }}" + + tasks: + + - name: generate commands IOSXR for primary interfaces + set_fact: + interfaces: "{{ interfaces | default([]) + [ item ] }}" + loop: "{{ hosts | json_query(\"[?name == '\" + inventory_hostname + \"'].interfaces[]\") }}" + + - name: do template IOSXR + set_fact: + command: "{{ lookup('template', './cisco2.j2') }}" + when: device_type == "IOSXR" + + - name: do template JUNOS + set_fact: + command: "{{ lookup('template', './juniper.j2') }}" + when: device_type == "JUNOS" + + - name: Debug output + debug: + var: command + + - name: set interfaces IOSXR + cisco.iosxr.iosxr_interfaces: + config: "{{ command }}" + when: device_type == "IOSXR" + + - name: set interfaces JUNOS + connection: local + juniper_junos_config: + load: 'merge' + lines: "{{ command }}" + format: 'set' + when: device_type == "JUNOS" diff --git a/2-transforming-configuration/output.json b/2-transforming-configuration/output.json new file mode 100644 index 0000000..c2ca0e3 --- /dev/null +++ b/2-transforming-configuration/output.json @@ -0,0 +1,177 @@ +[ + { + "name": "cisco1", + "ip": "172.16.1.2", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [ + { + "remote_address": "198.51.100.2", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "Loopback1", + "description": "PEER_A_NETWORK", + "vlan": "", + "ip_address": "10.0.1.1", + "subnet_mask": "", + "sub_ints": [], + "customer": "Acme Corporation" + }, + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.1.2", + "subnet_mask": "", + "sub_ints": [], + "customer": "Beta Industries" + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Gamma Enterprises" + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "foobar", + "vlan": "", + "ip_address": "172.17.1.16", + "subnet_mask": "", + "sub_ints": [ + { + "name": "GigabitEthernet0/0/0/1.100", + "description": "bar to foo", + "vlan": "100", + "ip_address": "198.51.100.1", + "subnet_mask": "", + "customer": "Epsilon Electronics" + }, + { + "name": "GigabitEthernet0/0/0/1.200", + "description": "foo to biz", + "vlan": "200", + "ip_address": "192.0.2.1", + "subnet_mask": "", + "customer": "Zeta Zoological" + } + ], + "customer": "Delta Dynamics" + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "NOT_IN_USE", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [], + "customer": "Eta Enterprises" + } + ] + }, + { + "name": "cisco2", + "ip": "172.16.1.3", + "platform": "cisco_xr", + "version": "7.9.1", + "peers": [], + "interfaces": [ + { + "name": "MgmtEth0/RP0/CPU0/0", + "description": "", + "vlan": "", + "ip_address": "172.16.1.3", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/0", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/1", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + }, + { + "name": "GigabitEthernet0/0/0/2", + "description": "", + "vlan": "", + "ip_address": "", + "subnet_mask": "", + "sub_ints": [] + } + ] + }, + { + "name": "juniper1", + "ip": "172.16.1.4", + "platform": "juniper_junos", + "version": "23.1R1.8", + "peers": [ + { + "remote_address": "198.51.100.1", + "md5_key": "foobar" + } + ], + "interfaces": [ + { + "name": "eth1", + "description": "foobar", + "sub_ints": [ + { + "name": "eth1.0", + "description": "", + "vlan": "", + "ip_address": "172.17.1.17", + "subnet_mask": "255.255.255.254", + "customer": "Theta Technologies" + }, + { + "name": "eth1.100", + "description": "foo", + "vlan": 100, + "ip_address": "198.51.100.2", + "subnet_mask": "255.255.255.0", + "customer": "Iota Innovations" + }, + { + "name": "eth1.200", + "description": "foo", + "vlan": 200, + "ip_address": "192.0.2.2", + "subnet_mask": "255.255.255.0", + "customer": "Kappa Kinetics" + } + ] + }, + { + "name": "eth2", + "description": "", + "sub_ints": [ + { + "name": "eth2.0", + "description": "EXAMPLE_NETWORK", + "vlan": "", + "ip_address": "10.0.2.1", + "subnet_mask": "255.255.255.0", + "customer": "Lambda Labs" + } + ] + } + ] + } +] diff --git a/2-transforming-configuration/show_version.yaml b/2-transforming-configuration/show_version.yaml new file mode 100644 index 0000000..0588c94 --- /dev/null +++ b/2-transforming-configuration/show_version.yaml @@ -0,0 +1,39 @@ +--- +- name: Show version on the hosts + hosts: all + gather_facts: no + + roles: + - Juniper.junos + + collections: + - cisco.iosxr + + tasks: + - name: "show version on IOSXR" + cisco.iosxr.iosxr_command: + commands: show interfaces + register: result + when: device_type == "IOSXR" + + - name: save result + set_fact: + output: "{{ result }}" + when: device_type == "IOSXR" + + - name: show version on JUNOS + connection: local + juniper_junos_command: + commands: + - "show version" + register: result + when: device_type == "JUNOS" + + - name: save result + set_fact: + output: "{{ result }}" + when: device_type == "JUNOS" + + - name: print results + debug: + var: output diff --git a/3-committing-configuration/.gitkeep b/3-committing-configuration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/3-committing-configuration/change_cisco_2_hostname.yaml b/3-committing-configuration/change_cisco_2_hostname.yaml new file mode 100644 index 0000000..247738e --- /dev/null +++ b/3-committing-configuration/change_cisco_2_hostname.yaml @@ -0,0 +1,15 @@ +--- +#Bonus track to show you are cool :-) +- name: Test to cisco + hosts: cisco + gather_facts: no + + collections: + - cisco.iosxr + + tasks: + + - name: Change description + cisco.iosxr.iosxr_config: + lines: hostname I_am_cool + when: "'cisco_2' in inventory_hostname" diff --git a/3-committing-configuration/config_interface_juniper.yaml b/3-committing-configuration/config_interface_juniper.yaml new file mode 100644 index 0000000..e1604d7 --- /dev/null +++ b/3-committing-configuration/config_interface_juniper.yaml @@ -0,0 +1,31 @@ +--- +#Bonus track to see the interface of the juniper +- name: Move VLANs between Cisco1 Juniper + hosts: all + gather_facts: no + + vars: + cisco_interface: "Gi0/0/0/0" + juniper_interface: "eth1" + cisco_interface_new: "Gi0/0/0/1" + cisco2_interface_new: "Gi0/0/0/0" + + collections: + - cisco.iosxr + - junipernetworks.junos + + roles: + - Juniper.junos + + tasks: + - name: Show config of the juniper interface + connection: local + juniper_junos_command: + commands: "show configuration interfaces {{ juniper_interface }}" + register: juniper_interface_info + when: "'juniper' in inventory_hostname" + + - name: Print Juniper command output + debug: + var: juniper_interface_info.stdout_lines + when: "'juniper' in inventory_hostname" diff --git a/3-committing-configuration/final_migration.yaml b/3-committing-configuration/final_migration.yaml new file mode 100644 index 0000000..437822a --- /dev/null +++ b/3-committing-configuration/final_migration.yaml @@ -0,0 +1,79 @@ +--- +- name: Move VLANs between Cisco_1 and Juniper + hosts: all + gather_facts: no + + vars: + cisco_1_interface_old: "Gi0/0/0/1" + juniper_interface: "eth1" + cisco_1_interface_new: "Gi0/0/0/0" + cisco_2_interface_new: "Gi0/0/0/0" + vlan_numbers: "{{ [] }}" + vlan_file: vlans_juniper.txt + #vlan_numbers: [] + + collections: + - cisco.iosxr + - junipernetworks.junos + - juniper.device + + roles: + - Juniper.junos + + tasks: + - name: Get VLANs on Juniper eth1 interface + connection: local + juniper_junos_command: + commands: "show configuration interfaces {{ juniper_interface }}" + register: juniper_interface_info + when: "'juniper' in inventory_hostname" + + + - name: Extract VLAN numbers from interface + connection: local + set_fact: + vlan_numbers: "{{ vlan_numbers | default([]) + [item.split()[1]] }}" + loop: "{{ juniper_interface_info.stdout_lines | select('match', '^ vlan-id ') | map('regex_replace',';','') | list }}" + when: "'juniper' in inventory_hostname" + + - name: Configure VLANS for new interface on Cisco_2 + cisco.iosxr.iosxr_l2_interfaces: + config: + - name: "{{ cisco_2_interface_new }}.{{ item }}" + encapsulation: + dot1q: "{{ item }}" + state: merged + with_items: "{{ hostvars['juniper']['vlan_numbers'] }}" + when: "'cisco_2' in inventory_hostname" + + - name: Configure VLANs for the new interface on Cisco_1 + cisco.iosxr.iosxr_l2_interfaces: + config: + - name: "{{ cisco_1_interface_new }}.{{ item }}" + encapsulation: + dot1q: "{{ item }}" + state: merged + with_items: "{{ hostvars['juniper']['vlan_numbers'] }}" + when: "'cisco_1' in inventory_hostname" + + + - name: Enable new interface on Cisco_2 + cisco.iosxr.iosxr_interfaces: + config: + - name: "{{ cisco_2_interface_new }}" + description: New_path + enabled: true + state: merged + with_items: "{{ hostvars['juniper']['vlan_numbers'] }}" + when: "'cisco_2' in inventory_hostname" + + - name: Disable old interface and enable new interface in Cisco_1 + cisco.iosxr.iosxr_interfaces: + config: + - name: "{{ cisco_1_interface_old }}" + enabled: false + - name: "{{ cisco_1_interface_new }}" + description: New_path + enabled: true + state: merged + when: "'cisco_1' in inventory_hostname" diff --git a/3-committing-configuration/from_juniper_to_cisco.yaml b/3-committing-configuration/from_juniper_to_cisco.yaml new file mode 100644 index 0000000..e255af0 --- /dev/null +++ b/3-committing-configuration/from_juniper_to_cisco.yaml @@ -0,0 +1,79 @@ +--- +- name: Move VLANs between Cisco_1 and uniper + hosts: all + gather_facts: no + + vars: + cisco_1_interface_old: "Gi0/0/0/1" + juniper_interface: "eth1" + cisco_1_interface_new: "Gi0/0/0/0" + cisco_2_interface_new: "Gi0/0/0/0" + vlan_numbers: "{{ [] }}" + vlan_file: vlans_juniper.txt + #vlan_numbers: [] + + collections: + - cisco.iosxr + - junipernetworks.junos + - juniper.device + + roles: + - Juniper.junos + + tasks: + - name: Get VLANs on Juniper eth1 interface + connection: local + juniper_junos_command: + commands: "show configuration interfaces {{ juniper_interface }}" + register: juniper_interface_info + when: "'juniper' in inventory_hostname" + + + - name: Extract VLAN numbers from interface + connection: local + set_fact: + vlan_numbers: "{{ vlan_numbers | default([]) + [item.split()[1]] }}" + loop: "{{ juniper_interface_info.stdout_lines | select('match', '^ vlan-id ') | map('regex_replace',';','') | list }}" + when: "'juniper' in inventory_hostname" + + #- name: "{{ cisco_2_interface_new }}.{{ item }}" + #description: "Subinterface {{ item }}" + #enabled: true + + - name: Configure VLANS in Cisco_2 + cisco.iosxr.iosxr_l2_interfaces: + config: + - name: "{{ cisco_2_interface_new }}.{{ item }}" + encapsulation: + dot1q: "{{ item }}" + state: merged + with_items: "{{ hostvars['juniper']['vlan_numbers'] }}" + when: "'cisco_2' in inventory_hostname" + + + - name: Enable new interface ind disable old interface in Cisco_2 + cisco.iosxr.iosxr_interfaces: + config: + - name: "{{ cisco_2_interface_new }}" + description: New_router_interface + enabled: true + - name: "{{ cisco_2_interface_old }}" + enabled: false + state: merged + with_items: "{{ hostvars['juniper']['vlan_numbers'] }}" + when: "'cisco_2' in inventory_hostname" + + - name: Disable interface in Cisco_1 + cisco.iosxr.iosxr_interfaces: + config: + - name: "{{ cisco_1_interface_old }}" + enabled: false + state: merged + when: "'cisco_1' in inventory_hostname" + + - name: Disable interface in Juniper: + junipernetworks.junos.junos_interfaces: + config: + name: "{{ juniper_interface }}" + enabled: false + when: "'juniper' in inventory_hostname" diff --git a/3-committing-configuration/get_all_configs.yaml b/3-committing-configuration/get_all_configs.yaml new file mode 100644 index 0000000..91d981a --- /dev/null +++ b/3-committing-configuration/get_all_configs.yaml @@ -0,0 +1,31 @@ +--- +- name: Retrieve Router Configurations + hosts: all + gather_facts: no + + collections: + - cisco.iosxr + + roles: + - Juniper.junos + + tasks: + - name: Retrieve Configuration from Cisco Routers + cisco.iosxr.iosxr_command: + commands: + - show running-config + register: router_config + when: device_type == "IOSXR" + + - name: Save Cisco Configuration to File + copy: + content: "{{ router_config.stdout[0] }}" + dest: "$HOME/{{ inventory_hostname }}.cfg" + when: device_type == "IOSXR" + + - name: Retrieve configuration and copy to a file for Juniper + connection: local + juniper_junos_config: + retrieve: "committed" + dest: "$HOME/{{ inventory_hostname }}.cfg" + when: device_type == "JUNOS" diff --git a/3-committing-configuration/hosts.yaml b/3-committing-configuration/hosts.yaml new file mode 100644 index 0000000..9b2d6c7 --- /dev/null +++ b/3-committing-configuration/hosts.yaml @@ -0,0 +1,24 @@ +--- +all: + children: + cisco: + hosts: + cisco_1: + ansible_host: 172.16.3.2 + device_type: "IOSXR" + cisco_2: + ansible_host: 172.16.3.3 + device_type: "IOSXR" + vars: + ansible_network_os: iosxr + ansible_user: clab + ansible_connection: ansible.netcommon.network_cli + ansible_ssh_pass: clab@123 + junos: + vars: + ansible_user: clab + ansible_ssh_pass: clab123 + hosts: + juniper: + ansible_host: 172.16.3.4 + device_type: "JUNOS" diff --git a/3-committing-configuration/vlans_juniper.yaml b/3-committing-configuration/vlans_juniper.yaml new file mode 100644 index 0000000..d901cc0 --- /dev/null +++ b/3-committing-configuration/vlans_juniper.yaml @@ -0,0 +1,31 @@ +--- +- name: Get the list of VLAN numbers on a Juniper interface + hosts: all + gather_facts: no + + vars: + juniper_interface: "eth1" + + roles: + - Juniper.junos + + tasks: + - name: Get VLANs on Juniper eth1 interface + connection: local + juniper_junos_command: + commands: "show configuration interfaces {{ juniper_interface }}" + register: juniper_interface_info + when: "'juniper' in inventory_hostname" + + + - name: Extract VLAN numbers from interface + connection: local + set_fact: + vlan_numbers: "{{ vlan_numbers | default([]) + [item.split()[1]] }}" + loop: "{{ juniper_interface_info.stdout_lines | select('match', '^ vlan-id ') | list }}" + when: "'juniper' in inventory_hostname" + + - name: Print Juniper command output + debug: + var: vlan_numbers + when: "'juniper' in inventory_hostname" diff --git a/GetStartedWithNetworkAutomation_slides.pdf b/GetStartedWithNetworkAutomation_slides.pdf new file mode 100644 index 0000000..b17eccd Binary files /dev/null and b/GetStartedWithNetworkAutomation_slides.pdf differ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..87fb382 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Shannon Byrnes, Maria Isabel Gandia, AJ Ragusa + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bb6d1a --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Get Started With Network Automation! +### Internet2 Community Exchange 2023 + +This git repository includes all code and assets to be used as part of the workshop. + +In addition, please see the following link to view the slides. Following along with the slides is highly encouraged. + +[Google Slides](GetStartedWithNetworkAutomation_slides.pdf) + +### Workshop Abstract + +This is a Getting Started workshop for network automation with Python. Topics will start at a high-level and then become more detailed. These topics include: A background and introduction to automation, Python and open source tooling (such as Netmiko), templating, sources of truth, projects, examples, automation best practices, barriers, and more. + +Familiarity with Python and Unix shell is helpful. Bringing a laptop is heavily encouraged for participants who wish to join the speaker in the weeds. diff --git a/internal-lab-setup-assets/Containerfile b/internal-lab-setup-assets/Containerfile new file mode 100644 index 0000000..735bf48 --- /dev/null +++ b/internal-lab-setup-assets/Containerfile @@ -0,0 +1,43 @@ +# Slim Ubuntu container with python 3.9 and pip pre-installed +FROM python:3.9.16-slim-bullseye + +# Install additional packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + sudo \ + locales-all \ + openssh-server \ + iproute2 \ + net-tools \ + iputils-ping \ + traceroute \ + pip \ + make \ + vim nano emacs + +# Create user account "lab" for workshop participant +RUN adduser --gecos "" clab && usermod -aG sudo clab + +# Extend PATH so all our new packages run easier +RUN export PATH=$PATH:/sbin:/usr/sbin + +# Set the user's password via environment variable. Passwords must be set +# to allow login. +# Note: These variable was pre-defined by the lab orchestrator +ENV CX23_LAB_PASSWORD= + +# Create an RSA key, required for SSH server. +RUN ssh-keygen -A + +# Pre-provision filepath required for SSH server. +RUN mkdir -p /run/sshd + +# Open port 22 for SSH +EXPOSE 22 + +# Prepare post-init script. +COPY ./workshop-init.sh /workshop-init.sh +COPY ./lab-makefile-for-bug /Makefile +RUN chmod +x /workshop-init.sh + +# Configure sshd as root process. If sshd terminates for some reason, the container will too. +ENTRYPOINT ["/workshop-init.sh"] diff --git a/internal-lab-setup-assets/Makefile b/internal-lab-setup-assets/Makefile new file mode 100644 index 0000000..8e81cd3 --- /dev/null +++ b/internal-lab-setup-assets/Makefile @@ -0,0 +1,30 @@ +# .phony: gen + +FILES=$(wildcard workshop[0-9]*.clab.yml) + +gen: clean + /srv/clab/venv/bin/python gen-topo.py + +deploy: gen + for name in workshop[0-9]*.clab.yml; do\ + containerlab deploy --reconfigure -t $${name};\ + # sleep 20;\ + done + +destroy: + for name in workshop[0-9]*.clab.yml; do\ + containerlab destroy --cleanup -t $${name};\ + # sleep 10;\ + done + rm -rf $(wildcard clab-automation-workshop-[0-9]*) + +inspect: + for name in workshop[0-9]*.clab.yml; do\ + containerlab inspect -t $${name};\ + done + +container: + docker build -t internet2/getting_started -f Containerfile . + +clean: + rm -rf $(FILES) diff --git a/internal-lab-setup-assets/README.md b/internal-lab-setup-assets/README.md new file mode 100644 index 0000000..603677e --- /dev/null +++ b/internal-lab-setup-assets/README.md @@ -0,0 +1,17 @@ +NOTE: This folder contains assets that were used to deploy the Containerlab environment that hosts 30 labs used in this workshop. It is included in this repo for any curious minds. + +Tune host settings https://hmntsharma.github.io/cisco-xrd/base_setup/#clone-the-xrd-tools-repository + +Set mgmt address in the config (instead of automatically assigning) as XRd has a bug and a container keeps its old address if it's been assigned a new address. containerlab is non-deterministic when assigning mgmt IPs. + +Review the `Makefile` in `Getting_Started` + +`make gen` to generate lab topologies based on `workshop.clab.yml.j2`. This currently creates 30 labs, set via `gen-topo.py`. +`make deploy` to start up all 30 labs. This will take some time but should complete without error. +`make destroy` to tear down all labs. +`make inspect` to output lab info (this show local IPs, not port bindings) +`make container` to build new lab container based on `Containerfile` + +You can also run `containerlab` commands directly but they need to be run as root or via `sudo`. + +You can probably use the output of `sudo containerlab inspect -t workshop1.clab.yml -f json` to create something similar to the `containerlab inspect` table output that displays the port bindings. i.e., use this to generate workshop instructions. diff --git a/internal-lab-setup-assets/lab-makefile-for-bug b/internal-lab-setup-assets/lab-makefile-for-bug new file mode 100644 index 0000000..6ca50c2 --- /dev/null +++ b/internal-lab-setup-assets/lab-makefile-for-bug @@ -0,0 +1,7 @@ +patch: + sed -i 's/if "network_os_model" not in device_info/if "network_os_model" not in device_info and True is False/g' $${HOME}/.ansible/collections/ansible_collections/cisco/iosxr/plugins/cliconf/iosxr.py + sed -i 's/data = self.get_command_output("show inventory")/#data = self.get_command_output("show inventory")/g'i $${HOME}/.ansible/collections/ansible_collections/cisco/iosxr/plugins/cliconf/iosxr.py + +unpatch: + sed -i 's/if "network_os_model" not in device_info and True is False/if "network_os_model" not in device_info/g' $${HOME}/.ansible/collections/ansible_collections/cisco/iosxr/plugins/cliconf/iosxr.py + sed -i 's/#data = self.get_command_output("show inventory")/data = self.get_command_output("show inventory")/g' $${HOME}/.ansible/collections/ansible_collections/cisco/iosxr/plugins/cliconf/iosxr.py diff --git a/internal-lab-setup-assets/startup-config/cisco1.conf b/internal-lab-setup-assets/startup-config/cisco1.conf new file mode 100644 index 0000000..9a11d06 --- /dev/null +++ b/internal-lab-setup-assets/startup-config/cisco1.conf @@ -0,0 +1,91 @@ +hostname {{ .ShortName }} +username clab + group root-lr + group cisco-support + secret 10 $6$7/293.lG/gI3....$qhqRPSKeGBilG47Ii/xlYF9xJVR1IK7bnw.7HHiVj4Aj8cb58bxiLAim8Xz.beUfJ6TQTP3vHUty3UO.4KmaL. +! +grpc + no-tls + address-family dual +! +line default + transport input ssh +! +call-home + service active + contact smart-licensing + profile CiscoTAC-1 + active + destination transport-method email disable + destination transport-method http + ! +! +netconf-yang agent + ssh +! +cdp +lldp +! +interface Loopback1 + description PEER_A_NETWORK + ipv4 address 10.0.1.1 255.255.255.0 +! +interface GigabitEthernet0/0/0/0 + description NOT_IN_USE + shutdown +! +interface GigabitEthernet0/0/0/1 + description foobar + ip address 172.17.1.16 255.255.255.254 +! +interface GigabitEthernet0/0/0/1.100 + description bar to foo + encapsulation dot1Q 100 + ip address 198.51.100.1 255.255.255.0 +! +interface GigabitEthernet0/0/0/1.200 + description foo to biz + encapsulation dot1Q 200 + ip address 192.0.2.1 255.255.255.0 +! +interface GigabitEthernet0/0/0/2 + description NOT_IN_USE + shutdown +! +route-policy PERMIT_ALL + pass +end-policy +! +router static + address-family ipv4 unicast + 0.0.0.0/0 MgmtEth0/RP0/CPU0/0 {{ .MgmtIPv4Gateway }} + ! + address-family ipv6 unicast + ::/0 MgmtEth0/RP0/CPU0/0 {{ .MgmtIPv6Gateway }} + ! +! +router bgp 64500 + bgp router-id 198.51.100.1 + address-family ipv4 unicast + redistribute connected + ! + neighbor-group PEER_B + remote-as 64501 + ebgp-multihop 2 + password encrypted 15140403062B39 + update-source GigabitEthernet0/0/0/1.100 + address-family ipv4 unicast + route-policy PERMIT_ALL in + route-policy PERMIT_ALL out + ! + ! + neighbor 198.51.100.2 + use neighbor-group PEER_B + ! +! +xml agent tty + iteration off +! +ssh server v2 +ssh server netconf vrf default +end diff --git a/internal-lab-setup-assets/startup-config/cisco2.conf b/internal-lab-setup-assets/startup-config/cisco2.conf new file mode 100644 index 0000000..eed41b9 --- /dev/null +++ b/internal-lab-setup-assets/startup-config/cisco2.conf @@ -0,0 +1,42 @@ +hostname {{ .ShortName }} +username clab + group root-lr + group cisco-support + secret 10 $6$7/293.lG/gI3....$qhqRPSKeGBilG47Ii/xlYF9xJVR1IK7bnw.7HHiVj4Aj8cb58bxiLAim8Xz.beUfJ6TQTP3vHUty3UO.4KmaL. +! +grpc + no-tls + address-family dual +! +line default + transport input ssh +! +call-home + service active + contact smart-licensing + profile CiscoTAC-1 + active + destination transport-method email disable + destination transport-method http + ! +! +netconf-yang agent + ssh +! +cdp +lldp +! +router static + address-family ipv4 unicast + 0.0.0.0/0 MgmtEth0/RP0/CPU0/0 {{ .MgmtIPv4Gateway }} + ! + address-family ipv6 unicast + ::/0 MgmtEth0/RP0/CPU0/0 {{ .MgmtIPv6Gateway }} + ! +! +xml agent tty + iteration off +! +ssh server v2 +ssh server netconf vrf default +end diff --git a/internal-lab-setup-assets/startup-config/juniper1.conf b/internal-lab-setup-assets/startup-config/juniper1.conf new file mode 100644 index 0000000..807f2ba --- /dev/null +++ b/internal-lab-setup-assets/startup-config/juniper1.conf @@ -0,0 +1,83 @@ +system { + root-authentication { + encrypted-password "$6$lB5c6$Zeud8c6IhCTE6hnZxXBl3ZMZTC2hOx9pxxYUWTHKW1oC32SATWLMH2EXarxWS5k685qMggUfFur1lq.o4p4cg1"; ## SECRET-DATA + } + login { + user clab { + uid 2000; + class super-user; + authentication { + encrypted-password "$6$lCT4O$miC8pBTrsdg5AI8wzsIb.oQPYosEaP2b1waGyrMV7QgBBjmrhjG37doJ094t6.m/Xv.p3EUAuZT0Fh7dkqt7b/"; ## SECRET-DATA + } + } + } + services { + ssh { + root-login allow; + } + netconf { + ssh; + } + } +} +interfaces { + eth1 { + description foobar; + unit 0 { + family inet { + address 172.17.1.17/31; + } + } + unit 100 { + description foo; + vlan-id 100; + family inet { + address 198.51.100.2/24; + } + } + unit 200 { + description foo; + vlan-id 200; + family inet { + address 192.0.2.2/24; + } + } + } + eth2 { + unit 0 { + description EXAMPLE_NETWORK; + family inet { + address 10.0.2.1/24; + } + } + } +} +policy-options { + policy-statement PERMIT_ALL { + term pass { + then accept; + } + } +} +routing-options { + router-id 198.51.100.2; + autonomous-system 64501; + static { + route 0.0.0.0/0 next-hop {{ .MgmtIPv4Gateway }}; + } +} +protocols { + bgp { + group PEER_A { + type external; + multihop; + import PERMIT_ALL; + authentication-key "$9$RXPcyKY2aHqfLxNbY2UD"; ## SECRET-DATA + export PERMIT_ALL; + neighbor 198.51.100.1 { + export PERMIT_ALL; + peer-as 64500; + } + } + } +} diff --git a/internal-lab-setup-assets/workshop-init.sh b/internal-lab-setup-assets/workshop-init.sh new file mode 100755 index 0000000..8f349a2 --- /dev/null +++ b/internal-lab-setup-assets/workshop-init.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "clab:$CX23_LAB_PASSWORD" | chpasswd + +echo "clab ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/clab +chmod 0440 /etc/sudoers.d/clab + +echo "syntax on" > /home/clab/.vimrc + +/sbin/sysctl -w net.ipv6.conf.all.disable_ipv6=1 +/sbin/sysctl -w net.ipv6.conf.default.disable_ipv6=1 +/sbin/sysctl -w net.ipv6.conf.lo.disable_ipv6=1 + +/usr/sbin/sshd -D diff --git a/internal-lab-setup-assets/workshop.clab.yml.j2 b/internal-lab-setup-assets/workshop.clab.yml.j2 new file mode 100644 index 0000000..e05a1b6 --- /dev/null +++ b/internal-lab-setup-assets/workshop.clab.yml.j2 @@ -0,0 +1,54 @@ +{%- set id = id|default(1) %} +{%- macro shared_node_settings(x) %} + mgmt_ipv4: 172.16.{{id}}.{{x}} + mgmt_ipv6: 2001:db8:16:{{id}}::{{x}} + ports: + - 2{{"%02d" % id}}{{x}}:22 +{%- endmacro -%} +name: automation-workshop-{{"%02d" % id}} + +mgmt: + network: automation-workshop-{{"%02d" % id}} + ipv4_subnet: 172.16.{{id}}.0/24 + ipv6_subnet: 2001:db8:16:{{id}}::/80 + +topology: + kinds: + cisco_xrd: + image: ios-xr/xrd-control-plane:7.9.1 + juniper_crpd: + image: crpd:23.1R1.8 + license: license.txt + startup-config: startup-config/juniper1.conf + exec: + - cli request system license add tmp/junos_sfnt_tmp.lic + + nodes: + {%- set x = 2 %} + cisco1: + kind: cisco_xrd + startup-config: startup-config/cisco1.conf + {{- shared_node_settings(x) }} + {%- set x = x+1 %} + cisco2: + kind: cisco_xrd + startup-config: startup-config/cisco2.conf + {{- shared_node_settings(x) }} + {%- set x = x+1 %} + juniper1: + kind: juniper_crpd + {{- shared_node_settings(x) }} + {%- set x = x+1 %} + ubuntu: + kind: linux + image: internet2/getting_started + {{- shared_node_settings(x) }} + env: + CX23_LAB_PASSWORD: Self-Nose-Reasonable-Dust-{{"%02d" % id}} + + links: + - endpoints: ["cisco1:Gi0-0-0-0", "cisco2:Gi0-0-0-0"] + - endpoints: ["cisco1:Gi0-0-0-1", "juniper1:eth1"] + - endpoints: ["cisco2:Gi0-0-0-1", "juniper1:eth2"] + - endpoints: ["ubuntu:eth1", "cisco1:Gi0-0-0-2"] + - endpoints: ["ubuntu:eth2", "cisco2:Gi0-0-0-2"] diff --git a/lab-assets/Containerfile b/lab-assets/Containerfile new file mode 100644 index 0000000..29ec98b --- /dev/null +++ b/lab-assets/Containerfile @@ -0,0 +1,33 @@ +# Slim Ubuntu container with python 3.9 and pip pre-installed +FROM python:3.9.16-slim-bullseye + +# Install additional packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + sudo \ + openssh-server \ + iputils-ping \ + vim nano emacs + +# Create user account "lab" for workshop participant +RUN adduser --gecos "" lab && usermod -aG sudo lab + +# Set the user's password via environment variable. Passwords must be set +# to allow login. +# Note: These variable was pre-defined by the lab orchestrator +ENV CX23_LAB_PASSWORD= + +# Create an RSA key, required for SSH server. +RUN ssh-keygen -A + +# Pre-provision filepath required for SSH server. +RUN mkdir -p /run/sshd + +# Open port 22 for SSH +EXPOSE 22 + +# Prepare post-init script. +COPY ./workshop-init.sh /workshop-init.sh +RUN chmod +x /workshop-init.sh + +# Configure sshd as root process. If sshd terminates for some reason, the container will too. +ENTRYPOINT ["/workshop-init.sh"] \ No newline at end of file diff --git a/lab-assets/workshop-init.sh b/lab-assets/workshop-init.sh new file mode 100644 index 0000000..c526e52 --- /dev/null +++ b/lab-assets/workshop-init.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +echo "lab:$CX23_LAB_PASSWORD" | chpasswd + +/usr/sbin/sshd -D \ No newline at end of file