import os import sys import time import logging import argparse import unittest import requests import xml.dom.minidom as md from xml.etree import ElementTree as ET from netaddr import IPNetwork from string import lower import mininet.node import mininet.topo import mininet.net import mininet.util from mininet.node import RemoteController from mininet.node import OVSKernelSwitch def create_network(controller_ip, controller_port): """Create topology and mininet network.""" topo = mininet.topo.Topo() topo.addSwitch('s1') topo.addHost('h1') topo.addHost('h2') topo.addLink('h1', 's1') topo.addLink('h2', 's1') switch=mininet.util.customConstructor( {'ovsk':OVSKernelSwitch}, 'ovsk,protocols=OpenFlow13') controller=mininet.util.customConstructor( {'remote': RemoteController}, 'remote,ip=%s:%s' % (controller_ip, controller_port)) net = mininet.net.Mininet(topo=topo, switch=switch, controller=controller) return net def get_flows(net): """Get list of flows from network's first switch. Return list of all flows on switch, sorted by duration (newest first) One flow is a dictionary with all flow's attribute:value pairs. Matches are stored under 'matches' key as another dictionary. Example: { 'actions': 'drop', 'cookie': '0xa,', 'duration': '3.434s,', 'hard_timeout': '12,', 'idle_timeout': '34,', 'matches': { 'ip': None, 'nw_dst': '10.0.0.0/24' }, 'n_bytes': '0,', 'n_packets': '0,', 'priority': '2', 'table': '1,' } """ log = logging.getLogger(__name__) def parse_matches(flow, matches): flow['matches'] = {} for match in matches: split_match = match.split('=', 1) if len(split_match) == 1: flow['matches'][split_match[0]] = None else: flow['matches'][split_match[0]] = split_match[1].rstrip(',') switch = net.switches[0] output = switch.cmdPrint( 'ovs-ofctl -O OpenFlow13 dump-flows %s' % switch.name) # output = switch.cmdPrint( # 'ovs-ofctl -F openflow10 dump-flows %s' % switch.name) log.debug('switch flow table: {}'.format(output)) flows = [] for line in output.splitlines()[1:]: flow = {} for word in line.split(): word.rstrip(',') try: key, value = word.split('=', 1) except ValueError: #TODO: need to figure out what to do here? continue if key == 'priority': values = value.split(',') flow[key] = values[0] parse_matches(flow, values[1:]) else: flow[key] = value.rstrip(',') flows.append(flow) # sort by duration return sorted(flows, key=lambda x: x['duration'].rstrip('s')) def translate_to_flow(flow, name, dictionary): switch_flow_name = dictionary[name] key_err = '{} needs to be present in flow definition. Flow definition ' \ 'was: {}.'.format(switch_flow_name, flow) assert switch_flow_name in flow, key_err return switch_flow_name def get_text_value(element): return element.childNodes[0].nodeValue def fallback_comparator(xml_element, switch_flow, kw): # print 'fallback_comparator-xml_element', xml_element.toxml() # print 'fallback_comparator: switch_flow', switch_flow # print 'fallback_comparator: kw', kws name = translate_to_flow(switch_flow, xml_element.nodeName, kw) actual = switch_flow[name] expected = xml_element.childNodes[0].nodeValue data = xml_element.toxml(), name, actual # print 'fallback_comparator: data', data assert expected == actual, 'xml part: %s && switch %s=%s' % data def default_comparator(xml_element, switch_flow): fallback_comparator(xml_element, switch_flow, keywords) def cookie_comparator(cookie, switch_flow): name = translate_to_flow(switch_flow, cookie.nodeName, keywords) actual = int(switch_flow[name], 0) expected = int(cookie.childNodes[0].nodeValue) data = cookie.toxml(), name, actual assert expected == actual, 'xml part: %s && switch %s=%s' % data def ethernet_address_comparator(child, actual_match, kw): expected_address = child.getElementsByTagName("address")[0].childNodes[0].data actual_address = actual_match[kw.get(child.nodeName)] data = child.toxml(), kw.get(child.nodeName), actual_address assert lower(expected_address) == lower(actual_address), \ 'xml address: %s && actual address %s=%s' % data def ethernet_match_comparator(expected_match, actual_match, kw): def compare_etype(child, actual_match, kw): expected_etype = int(child.getElementsByTagName("type")[0].childNodes[0].data) name = kw.get(child.nodeName) data = child.toxml(), name, actual_match if expected_etype == 2048: # IP assert ((actual_match.get('ip', 'IP Not-present') is None) or \ (actual_match.get('tcp', 'TCP Not-present') is None) or \ (actual_match.get('sctp', 'SCTP Not-present') is None) or \ (actual_match.get('udp', 'UDP Not-present') is None)), \ 'Expected etype %s && actual etype %s=%s' % data elif expected_etype == 2054: #ARP assert actual_match.get('arp', 'ARP Not-present') is None, \ 'Expected etype %s && actual etype %s=%s' % data else: actual_etype = int(actual_match[name], 16) assert expected_etype == actual_etype, 'xml etype: %s && actual etype %s=%s' % data ETH_COMPARATORS = { 'ethernet-type': compare_etype, 'ethernet-source': ethernet_address_comparator, 'ethernet-destination': ethernet_address_comparator, } # print 'ethernet_match_comparator-expected_match:', expected_match.toxml() # print 'ethernet_match_comparator-actual_match:', actual_match # print 'ethernet_match_comparator-keywords:', keywords for child in expected_match.childNodes: if child.nodeType is expected_match.TEXT_NODE: continue comparator = ETH_COMPARATORS.get(child.nodeName) comparator(child, actual_match, kw) def ip_v4_comparator(expected_match, actual_match, kw): # print 'ip_v4_comparator:', expected_match.toxml(), actual_match # print 'ip_v4_comparator-actual_match:', actual_match expected_value = expected_match.childNodes[0].data actual_value = actual_match[kw.get(expected_match.nodeName)] data = expected_match.toxml(), kw.get(expected_match.nodeName), actual_value assert IPNetwork(expected_value) == IPNetwork(actual_value), 'xml part: %s && address %s=%s' % data def ip_match_comparator(expected_match, actual_match, kw): def compare_proto(child, actual_match, kw): print 'compare_proto:', child.toxml(), actual_match expected_proto = int(child.childNodes[0].data) name = child.nodeName data = expected_match.toxml(), name, actual_match if expected_proto == 6: # TCP assert actual_match.get('tcp', 'TCP Not-present') is None, \ 'ip protocol type: expected %s, actual %s=%s' % data elif expected_proto == 17: #UDP assert actual_match.get('udp', 'UDP Not-present') is None, \ 'ip protocol type: expected %s, actual %s=%s' % data elif expected_proto == 132: #SCTP assert actual_match.get('sctp', 'SCTP Not-present') is None, \ 'ip protocol type: expected %s, actual %s=%s' % data else: fallback_comparator(child, actual_match, kw) def compare_dscp(child, actual_match, kw): # print 'compare_dscp:', child.toxml(), actual_match expected_dscp = int(child.childNodes[0].data) name = kw.get(child.nodeName) actual_dscp = int(actual_match[name]) data = child.toxml(), name, actual_match assert (expected_dscp * 4) == actual_dscp, 'dscp: expected %s, actual %s=%s' % data IP_MATCH_COMPARATORS = { 'ip-protocol': compare_proto, 'ip-dscp': compare_dscp, 'ip-ecn': fallback_comparator, } # print 'ip_match_comparator:', expected_match.toxml(), actual_match for child in expected_match.childNodes: if child.nodeType is expected_match.TEXT_NODE: continue comparator = IP_MATCH_COMPARATORS.get(child.nodeName) comparator(child, actual_match, kw) def match_comparator(expected_match, switch_flow): MATCH_COMPARATORS = { 'arp-source-hardware-address': ethernet_address_comparator, 'arp-target-hardware-address': ethernet_address_comparator, 'ethernet-match': ethernet_match_comparator, 'ip-match': ip_match_comparator, 'ipv4-destination': ip_v4_comparator, 'ipv4-source': ip_v4_comparator, 'default': fallback_comparator, } actual_match = switch_flow['matches'] # print 'match_comparator-expected_match:', expected_match.toxml() # print 'match_comparator-actual_match:', actual_match # print 'match_comparator: keywords', keywords for child in expected_match.childNodes: if child.nodeType is expected_match.TEXT_NODE: continue comparator = MATCH_COMPARATORS.get(child.nodeName, MATCH_COMPARATORS['default']) comparator(child, actual_match, match_keywords) def actions_comparator(actions, switch_flow): # print 'actions_comparator:', actions, switch_flow actual_actions = switch_flow['actions'].split(",") # print 'actions_comparator:', actual_actions for action in actions.childNodes: if action.nodeType is actions.TEXT_NODE: continue action_name = action.childNodes[3].nodeName expected_action = action_keywords.get(action_name) data = action.toxml(), expected_action # print 'actions_comparator:', data assert expected_action in actual_actions, 'xml part:\n%s\n expected action: %s' % data def null_comparator(element, switch_flow): pass def instructions_comparator(instructions_element, switch_flow): INSTRUCTION_COMPARATORS = { 'apply-actions': actions_comparator, 'default': null_comparator, } # print 'instructions_comparator:', instructions_element, switch_flow instructions = instructions_element.childNodes for instruction in instructions_element.childNodes: if instruction.nodeType is instructions_element.TEXT_NODE: continue for itype in instruction.childNodes: if itype.nodeType is itype.TEXT_NODE: continue comparator = INSTRUCTION_COMPARATORS.get(itype.nodeName, INSTRUCTION_COMPARATORS['default']) comparator(itype, switch_flow) COMPARATORS = { 'cookie': cookie_comparator, 'instructions': instructions_comparator, 'match': match_comparator, 'default': default_comparator, } def all_nodes(xml_root): """ Generates every non-text nodes. """ current_nodes = [xml_root] next_nodes = [] while len(current_nodes) > 0: for node in current_nodes: if node.nodeType != xml_root.TEXT_NODE: yield node next_nodes.extend(node.childNodes) current_nodes, next_nodes = next_nodes, [] def check_elements(xmlstr, keywords): # namespace = 'urn:opendaylight:flow:inventory' tree = md.parseString(xmlstr) for element in all_nodes(tree.documentElement): # switch flow object contains only some data from xml if element.nodeName not in keywords: # print 'check_elements: element.nodeName', element.nodeName, 'NOT in keywords' continue yield element raise StopIteration() class TestOpenFlowXMLs(unittest.TestCase): @classmethod def setUpClass(cls): cls.net = create_network(cls.host, cls.mn_port) cls.net.start() time.sleep(15) @classmethod def tearDownClass(cls): cls.net.stop() def get_values(node, *tags): result = {tag: None for tag in tags} for node in all_nodes(node): if node.nodeName in result and len(node.childNodes) > 0: result[node.nodeName] = node.childNodes[0].nodeValue return result def generate_tests_from_xmls(path, xmls=None): # generate test function from path to request xml def generate_test(path_to_xml): xml_string = '' with open(path_to_xml) as f: xml_string = f.read() tree = md.parseString(xml_string) ids = get_values(tree.documentElement, 'table_id', 'id') def new_test(self): log = logging.getLogger(__name__) # send request throught RESTCONF data = (self.host, self.port, ids['table_id'], ids['id']) url = 'http://%s:%d/restconf/config/opendaylight-inventory:nodes' \ '/node/openflow:1/table/%s/flow/%s' % data headers = { 'Content-Type': 'application/xml', 'Accept': 'application/xml', } log.info('sending request to url: {}'.format(url)) rsp = requests.put(url, auth=('admin', 'admin'), data=xml_string, headers=headers) log.info('received status code: {}'.format(rsp.status_code)) log.debug('received content: {}'.format(rsp.text)) assert rsp.status_code == 204 or rsp.status_code == 200, 'Status' \ ' code returned %d' % rsp.status_code # check request content against restconf's datastore response = requests.get(url, auth=('admin', 'admin'), headers={'Accept': 'application/xml'}) assert response.status_code == 200 req = ET.tostring(ET.fromstring(xml_string)) res = ET.tostring(ET.fromstring(response.text)) assert req == res, 'uploaded and stored xml, are not the same\n' \ 'uploaded: %s\nstored:%s' % (req, res) # collect flow table state on switch switch_flows = get_flows(self.net) assert len(switch_flows) > 0 # compare requested object and flow table state for important_element in check_elements(xml_string, keywords): # log.info('important element: {}'.format(important_element.nodeName)) comparator = COMPARATORS.get(important_element.nodeName, COMPARATORS['default']) comparator(important_element, switch_flows[0]) return new_test # generate list of available xml requests xmlfiles = None if xmls is not None: xmlfiles = ('f%d.xml' % fid for fid in xmls) else: xmlfiles = (xml for xml in os.listdir(path) if xml.endswith('.xml')) # define key getter for sorting def get_test_number(test_name): return int(test_name[1:-4]) for xmlfile in xmlfiles: test_name = 'test_xml_%04d' % get_test_number(xmlfile) setattr(TestOpenFlowXMLs, test_name, generate_test(os.path.join(path, xmlfile))) if __name__ == '__main__': # set up logging logging.basicConfig(level=logging.DEBUG) # parse cmdline arguments parser = argparse.ArgumentParser(description='Run switch <-> ODL tests ' 'defined by xmls.') parser.add_argument('--odlhost', default='127.0.0.1', help='host where ' 'odl controller is running') parser.add_argument('--odlport', type=int, default=8080, help='port on ' 'which odl\'s RESTCONF is listening') parser.add_argument('--mnport', type=int, default=6653, help='port on ' 'which odl\'s controller is listening') parser.add_argument('--xmls', default=None, help='generete tests only ' 'from some xmls (i.e. 1,3,34) ') args = parser.parse_args() # set host and port of ODL controller for test cases TestOpenFlowXMLs.port = args.odlport TestOpenFlowXMLs.host = args.odlhost TestOpenFlowXMLs.mn_port = args.mnport keywords = None with open('keywords.csv') as f: keywords = dict(line.strip().split(';') for line in f if not line.startswith('#')) match_keywords = None with open('match-keywords.csv') as f: match_keywords = dict(line.strip().split(';') for line in f if not line.startswith('#')) action_keywords = None with open('action-keywords.csv') as f: action_keywords = dict(line.strip().split(';') for line in f if not line.startswith('#')) # fix arguments for unittest del sys.argv[1:] # generate tests for TestOpenFlowXMLs if args.xmls is not None: xmls = map(int, args.xmls.split(',')) generate_tests_from_xmls('xmls', xmls) else: generate_tests_from_xmls('xmls') # run all tests unittest.main()