#!/usr/bin/env python3 import argparse import networkx as nx import ipaddress import yaml import sys from networkx.drawing.nx_agraph import to_agraph from jinja2 import Environment, FileSystemLoader parser = argparse.ArgumentParser(description='ClosX - Clos Network Generator') parser.add_argument('-ipr', action="store", dest="ip_range") parser.add_argument('-mgmtr', action="store", dest="mgmt_range") parser.add_argument('-spine', action="store", dest="spine_size") parser.add_argument('-tier2', action="store", dest="tier2_size") parser.add_argument('-tier1', action="store", dest="tier1_size") parser.add_argument('-vendor', action="store", dest="vendor") args = parser.parse_args() class Clos(): def __init__(self, spine_size, tier2_size, tier1_size, ip_range, mgmt_range, vendor): ''' Summary: Takes a router list and builds a vars_dict to pass to the associated Jinja template. Assigns: self.G: Networkx graph object self.nodes: Dict of lists, keyed by node type, dict example: { 'spine_list': ['s1-r1', 's1-r2', 's1-r3', 's1-r4', 's1-r5', 's1-r6', 's1-r7', 's1-r8'], 'tier2_list': ['t2-r1', 't2-r2', 't2-r3', 't2-r4', 't2-r5', 't2-r6', 't2-r7', 't2-r8', 't2-r9', 't2-r10', 't2-r11', 't2-r12', 't2-r13', 't2-r14', 't2-r15', 't2-r16'], 'tier1_list': ['t1-r1', 't1-r2', 't1-r3', 't1-r4', 't1-r5', 't1-r6', 't1-r7', 't1-r8', 't1-r9', 't1-r10', 't1-r11', 't1-r12', 't1-r13', 't1-r14', 't1-r15', 't1-r16'] } self.edges: List of edge tuples, list example: [ ('s1-r1', 't2-r1'), ('s1-r1', 't2-r2'), ('s1-r1', 't2-r3'), ('s1-r1', 't2-r4'), ('s1-r1', 't2-r5'), ('s1-r1', 't2-r6'), ...etc ] self.spine_size: Size of spine as str, always converted to int self.tier2_size: Size of tier2 as str, always converted to int self.tier1_size: Size of tier1 as str, always converted to int self.ip_range: IP range to split into /31 subnets as str, always converted to ipnetwork object self.mgmt_range: IP range to split into /32 management IPs as str, always converted to ipnetwork object self.total_nodes: Sum of spine_size, tier2_size and tier1_size as int self.vendor: Vendor template to use for rendering as str self.image_name: Name of the networkx graph object visualisation file as str ''' self.G = nx.Graph() self.nodes = {} self.edges = [] self.spine_size = spine_size self.tier2_size = tier2_size self.tier1_size = tier1_size self.ip_range = ip_range self.mgmt_range = mgmt_range self.total_nodes = int(spine_size) + int(tier2_size) + int(tier1_size) self.vendor = vendor self.image_name = 'clos.png' def build(self): ''' Summary: Builds the Clos object. Populates self.nodes and self.edges based on user provided args. Populates self.G.nodes and self.G.edges with self.nodes and self.edges. Nodes also include /32 IPs for management. Edges also include /31 IPs and port indexes. Topology is drawn and visualised with pygraphviz. Configuration is rendered with YAML and Jinja. Takes: self: Clos object Produces: Networkx graph object visualisation file. Full Clos network configuration. ''' self.nodes['spine_list'] = ['s1-r' + str(node + 1) for node in range(int(self.spine_size))] self.nodes['tier2_list'] = ['t2-r' + str(node + 1) for node in range(int(self.tier2_size))] self.nodes['tier1_list'] = ['t1-r' + str(node + 1) for node in range(int(self.tier1_size))] mgmt_ips = self.get_mgmt_ips() node_index = 0 for node_lists, nodes in self.nodes.items(): for node in nodes: self.G.add_node(node) self.G.nodes[node]['mgmt_ip'] = str(mgmt_ips[node_index].hosts()[0]) node_index +=1 for spine_router in self.nodes['spine_list']: for tier2_router in self.nodes['tier2_list']: self.edges.append((spine_router, tier2_router)) for tier2_router in self.nodes['tier2_list']: for tier1_router in self.nodes['tier1_list']: self.edges.append((tier2_router, tier1_router)) p2p_ips = self.get_p2p_ips() port_indexes = self.get_port_indexes() edge_index = 0 for edge in self.edges: self.G.add_edge(edge[0], edge[1]) nx.set_edge_attributes( self.G, { (edge[0], edge[1]): {'a_end_ip': str(list(p2p_ips[edge_index].hosts())[0]), 'a_end': edge[0], 'a_end_index': port_indexes[edge_index]['a_end_index'], 'b_end_ip': str(list(p2p_ips[edge_index].hosts())[1]), 'b_end': edge[1], 'b_end_index': port_indexes[edge_index]['b_end_index']} } ) edge_index += 1 print('Drawing topology...') vis = to_agraph(self.G) vis.layout('dot') vis.draw(self.image_name) print(f'Topology drawn, saved as: {self.image_name}') print(f'Rendered {self.total_nodes} Nodes and {len(self.edges)} Edges') self.render_config('spine_list') self.render_config('tier2_list') self.render_config('tier1_list') def get_p2p_ips(self): ''' Summary: Takes an IP range and splits it into /31 subnets. Exits if the number of edges exceeds the number of /31 subnets, prompts user to provide a bigger IP range. Takes: self: Clos object Returns: p2p_ips: List of /31 subnets ''' ips = ipaddress.ip_network(self.ip_range) p2p_ips = list(ips.subnets(new_prefix=31)) if len(self.edges) > len(p2p_ips): sys.exit(f'Insufficient IP space for allocating required amount of /31s. Edges: {len(self.edges)}, /31s: {len(p2p_ips)}, provide a bigger IP range') return p2p_ips def get_mgmt_ips(self): ''' Summary: Takes an IP range and splits it into /32 management IPs. Exits if the number of nodes exceeds the number of /32 management IPs, prompts user to provide a bigger IP range. Takes: self: Clos object Returns: mgmt_ips: List of /32 management IPs ''' ips = ipaddress.ip_network(self.mgmt_range) mgmt_ips = list(ips.subnets(new_prefix=32)) if self.total_nodes > len(mgmt_ips): sys.exit(f'Insufficient IP space for allocating required amount of /32s. Nodes: {len(self.total_nodes)}, /32s: {len(mgmt_ips)}, provide a bigger mgmt IP range') return mgmt_ips def render_config(self, router_list): ''' Summary: Takes a list of router names and builds a vars_dict dict for each router in it to pass to the associated Jinja template for rendering. Iterates edges in the networkx graph object to extract edge attributes and add them into vars_dict. Template to render against is matched to self.vendor, provided as an arg at runtime. Configs are written to the configs/ folder named as hostname.conf. vars_dict contains: - hostname - mgmt_ip - interfaces: Dict of dicts, keyed by index, containing: - a_end_ip - a_end (hostname) - a_end_index - b_end_ip - b_end (hostname) - b_end_index vars_dict dict example: { 'hostname': 's1-r1', 'mgmt_ip': '172.16.0.0', 'interfaces': { 0: {'a_end_ip': '10.0.0.0', 'a_end': 's1-r1', 'a_end_index': 24, 'b_end_ip': '10.0.0.1', 'b_end': 't2-r1', 'b_end_index': 0}, 1: {'a_end_ip': '10.0.0.2', 'a_end': 's1-r1', 'a_end_index': 25, 'b_end_ip': '10.0.0.3', 'b_end': 't2-r2', 'b_end_index': 0}, 2: {'a_end_ip': '10.0.0.4', 'a_end': 's1-r1', 'a_end_index': 26, 'b_end_ip': '10.0.0.5', 'b_end': 't2-r3', 'b_end_index': 0}, 3: {'a_end_ip': '10.0.0.6', 'a_end': 's1-r1', 'a_end_index': 27, 'b_end_ip': '10.0.0.7', 'b_end': 't2-r4', 'b_end_index': 0}, ...etc } } Takes: self: Clos object router_list: Name of router list to render configuration for ''' for router in self.nodes[router_list]: vars_dict = {} vars_dict.update({'hostname': router, 'interfaces': {}}) for node in self.G.nodes(data=True): if router == node[0]: vars_dict.update({'mgmt_ip': node[1]['mgmt_ip']}) edge_index = 0 for edge in self.G.edges(router, data=True): vars_dict['interfaces'].update({edge_index: edge[2]}) edge_index += 1 template_path = Environment(loader=FileSystemLoader('templates/'), trim_blocks=True, lstrip_blocks=True) template = template_path.get_template(self.vendor + '.j2') rendered_config = template.render(vars_dict) config_path = 'configs/' + self.vendor + '/' + router + '.conf' with open(config_path, 'w') as configuration: configuration.write(rendered_config) print(f'Rendered {config_path}') def get_port_indexes(self): ''' Summary: Calculates port indexes for both sides of edges in the networkx graph object. The lowest available index is always used. Once used, port indexes are removed from the free_port_indexes uplinks or downlinks list. Design principles of a Clos network define that port radix should be equal north and south. Max amount of uplink or downlink ports is 24. Exits if the number of port indexes required exceed the max, prompts user to reduce spine, tier2 or tier1 size. free_port_indexes dict example: { 's1-r1': { 'uplinks': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23], 'downlinks': [24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47] } } Takes: self: Clos object Returns: allocated_port_indexes: Dict of dicts, keyed by edge index, containing assigned a and b end indexes. allocated_port_indexes dict example: { 0: {'a_end_index': 24, 'b_end_index': 0}, 1: {'a_end_index': 25, 'b_end_index': 0}, 2: {'a_end_index': 26, 'b_end_index': 0}, 3: {'a_end_index': 27, 'b_end_index': 0}, ...etc } ''' if int(self.spine_size) > 24 or int(self.tier2_size) > 24 or int(self.tier1_size) > 24: sys.exit('Amount of required port indexes exceeds free port indexes, decrease the size of spine/tier2/tier1 if they exceed 24') allocated_port_indexes = {} free_port_indexes = {} for node in self.G.nodes: free_port_indexes.update({node: {'uplinks': [i for i in range(0, 24)], 'downlinks': [i for i in range(24, 48)]}}) edge_index = 0 for edge in self.edges: lowest_available_downlink = min(free_port_indexes[edge[0]]['downlinks']) lowest_available_uplink = min(free_port_indexes[edge[1]]['uplinks']) allocated_port_indexes.update({edge_index: {'a_end_index': lowest_available_downlink, 'b_end_index': lowest_available_uplink}}) free_port_indexes[edge[0]]['downlinks'].remove(lowest_available_downlink) free_port_indexes[edge[1]]['uplinks'].remove(lowest_available_uplink) edge_index += 1 return allocated_port_indexes if __name__ == "__main__": Clos = Clos(args.spine_size, args.tier2_size, args.tier1_size, args.ip_range, args.mgmt_range, args.vendor) Clos.build()