stress test related improvement
[openflowplugin.git] / test-scripts / odl_tests.py
1 import os
2 import sys
3 import time
4 import logging
5 import argparse
6 import unittest
7 import xml.dom.minidom as md
8 from xml.etree import ElementTree as ET
9 from string import lower
10
11 import requests
12 from netaddr import IPNetwork
13 import mininet.node
14 import mininet.topo
15 import mininet.net
16 import mininet.util
17 from mininet.node import RemoteController
18 from mininet.node import OVSKernelSwitch
19
20 import xmltodict
21
22 def create_network(controller_ip, controller_port):
23     """Create topology and mininet network."""
24     topo = mininet.topo.Topo()
25
26     topo.addSwitch('s1')
27     topo.addHost('h1')
28     topo.addHost('h2')
29
30     topo.addLink('h1', 's1')
31     topo.addLink('h2', 's1')
32
33     switch=mininet.util.customConstructor(
34         {'ovsk':OVSKernelSwitch}, 'ovsk,protocols=OpenFlow13')
35
36     controller=mininet.util.customConstructor(
37         {'remote': RemoteController}, 'remote,ip=%s,port=%s' % (controller_ip,
38                                                            controller_port))
39
40
41     net = mininet.net.Mininet(topo=topo, switch=switch, controller=controller)
42
43     return net
44
45
46 def get_flows(net):
47     """Get list of flows from network's first switch.
48
49     Return list of all flows on switch, sorted by duration (newest first)
50     One flow is a dictionary with all flow's attribute:value pairs. Matches
51     are stored under 'matches' key as another dictionary.
52     Example:
53
54         {
55             'actions': 'drop',
56             'cookie': '0xa,',
57             'duration': '3.434s,',
58             'hard_timeout': '12,',
59             'idle_timeout': '34,',
60             'matches': {
61                 'ip': None,
62                 'nw_dst': '10.0.0.0/24'
63             },
64             'n_bytes': '0,',
65             'n_packets': '0,',
66             'priority': '2',
67             'table': '1,'
68         }
69
70     """
71     log = logging.getLogger(__name__)
72     def parse_matches(flow, matches):
73         flow['matches'] = {}
74
75         for match in matches:
76             split_match = match.split('=', 1)
77             if len(split_match) == 1:
78                 flow['matches'][split_match[0]] = None
79             else:
80                 flow['matches'][split_match[0]] = split_match[1].rstrip(',')
81
82     switch = net.switches[0]
83     output = switch.cmdPrint(
84         'ovs-ofctl -O OpenFlow13 dump-flows %s' % switch.name)
85 #    output = switch.cmdPrint(
86 #        'ovs-ofctl -F openflow10 dump-flows %s' % switch.name)
87
88     log.debug('switch flow table: {}'.format(output))
89
90     flows = []
91
92     for line in output.splitlines()[1:]:
93         flow = {}
94         for word in line.split():
95             word.rstrip(',')
96             try:
97                 key, value = word.split('=', 1)
98             except ValueError:
99                 #TODO: need to figure out what to do here?
100                 continue
101
102             if key == 'priority':
103                 values = value.split(',')
104                 flow[key] = values[0]
105                 parse_matches(flow, values[1:])
106             else:
107                 flow[key] = value.rstrip(',')
108
109         flows.append(flow)
110
111     # sort by duration 
112     return sorted(flows, key=lambda x: x['duration'].rstrip('s'))
113
114
115 def translate_to_flow(flow, name, dictionary):
116     switch_flow_name = dictionary[name]
117
118     key_err = '{} needs to be present in flow definition. Flow definition ' \
119               'was: {}.'.format(switch_flow_name, flow)
120     assert switch_flow_name in flow, key_err
121     return switch_flow_name
122
123
124 def get_text_value(element):
125     return element.childNodes[0].nodeValue
126
127
128 def compare_elements(expected_match, actual_match, kw, comparators, default):
129     for child in expected_match.childNodes:
130         if child.nodeType is expected_match.TEXT_NODE:
131             continue
132       
133         comparator = comparators.get(child.nodeName, default)
134         comparator(child, actual_match, kw)
135
136
137 def fallback_comparator(xml_element, switch_flow, kw):
138     # print 'fallback_comparator-xml_element', xml_element.toxml()
139     # print 'fallback_comparator: switch_flow', switch_flow
140     # print 'fallback_comparator: kw', kws
141
142     name = translate_to_flow(switch_flow, xml_element.nodeName, kw)
143
144     actual = switch_flow[name]
145     expected = xml_element.childNodes[0].nodeValue
146
147     data = xml_element.toxml(), name, actual
148     # print 'fallback_comparator: data', data
149
150     assert expected == actual, 'xml part: %s && switch %s=%s' % data
151
152
153 def default_comparator(xml_element, switch_flow):
154     fallback_comparator(xml_element, switch_flow, keywords)
155
156
157 def integer_comparator(expected, actual, kw, base):
158     expected_value = int(expected.childNodes[0].data)
159
160     name = kw.get(expected.nodeName)
161     actual_value = int(actual[name], base)
162
163     data = expected.toxml(), name, actual
164     assert expected_value == actual_value, \
165         'xml value: %s && actual value %s=%s' % data
166
167
168 def cookie_comparator(cookie, switch_flow):
169     integer_comparator(cookie, switch_flow, keywords, 16)
170
171
172 def ethernet_address_comparator(child, actual_match, kw):
173     expected_address = child.getElementsByTagName("address")[0].childNodes[0].data
174     actual_address = actual_match[kw.get(child.nodeName)]
175
176     data = child.toxml(), kw.get(child.nodeName), actual_address
177
178     assert lower(expected_address) == lower(actual_address), \
179         'xml address: %s && actual address %s=%s' % data
180
181
182 def masked_value_hex_comparator(child, actual_match, kw, vname, kname):
183     print 'masked_value_hex_comparator', child.toxml(), actual_match, \
184         vname, kname, child.nodeName
185
186     emd = int(child.getElementsByTagName(vname)[0].childNodes[0].data)
187
188     name = kw.get(vname)
189     data = child.toxml(), name, actual_match
190     print 'masked_value_hex_comparator', name
191
192     amd = int(actual_match[name], 16)
193
194     emasks = child.getElementsByTagName(kname)
195     if len(emasks) != 0:
196         print 'masked_value_hex_comparator - mask present:', \
197             emasks[0].childNodes[0].data
198
199     assert emd == amd, 'metadata: expected %s && actual %s=%s' % data
200
201
202
203 def proto_match_comparator(expected_match, actual_match, kw):
204
205     def compare_base10_integer(expected_match, actual_match, kw):
206         integer_comparator(expected_match, actual_match, kw, 10)
207
208     def compare_vlan_id(expected_match, actual_match, kw):
209         integer_comparator(expected_match.getElementsByTagName('vlan-id')[0], \
210                            actual_match, kw, 10)
211
212     def compare_pbb(expected, actual, kw):
213         masked_value_hex_comparator(expected, actual, kw, \
214                                     'pbb-isid', 'pbb-mask')
215
216     PROTO_COMPARATORS = {
217         'vlan-id': compare_vlan_id,
218         'pbb': compare_pbb
219     }    
220
221     # print 'ethernet_match_comparator-expected_match:', expected_match.toxml()
222     # print 'ethernet_match_comparator-actual_match:', actual_match
223
224     compare_elements(expected_match, actual_match, kw, \
225                      PROTO_COMPARATORS, compare_base10_integer)
226
227
228 #def masked_value_hex_comparator(child, actual_match, kw):
229 #    emd = int(child.getElementsByTagName("metadata")[0].childNodes[0].data)
230 #
231 #    name = kw.get(child.nodeName)
232 #    data = child.toxml(), name, actual_match
233 #
234 #    amd = int(actual_match[kw.get(name)], 16)
235 #
236 #    emasks = child.getElementsByTagName("metadata-mask")
237 #    if len(emasks) != 0:
238 #        print 'mask present'
239 #
240 #    assert emd == amd, 'metadata: expected %s && actual %s=%s' % data
241
242
243
244 def ethernet_match_comparator(expected_match, actual_match, kw):
245     def compare_etype(child, actual_match, kw):
246         expected_etype = \
247             int(child.getElementsByTagName("type")[0].childNodes[0].data)
248         name = kw.get(child.nodeName)
249         data = child.toxml(), name, actual_match
250
251         if expected_etype == 2048: # IPv4
252             assert((actual_match.get('ip', 'IP Not-present') is None) or \
253                    (actual_match.get('tcp', 'TCP Not-present') is None) or \
254                    (actual_match.get('icmp', 'ICMP Not-present') is None) or \
255                    (actual_match.get('sctp', 'SCTP Not-present') is None) or \
256                    (actual_match.get('udp', 'UDP Not-present') is None)), \
257                 'Expected etype %s && actual etype %s=%s' % data
258  
259         elif expected_etype == 2054: # ARP
260             assert actual_match.get('arp', 'ARP Not-present') is None, \
261                      'Expected etype %s && actual etype %s=%s' % data
262
263         elif expected_etype == 34887: # MPLS
264             assert actual_match.get('mpls', 'MPLS Not-present') is None, \
265                      'Expected etype %s && actual etype %s=%s' % data
266
267         elif expected_etype == 34525: # IPv6
268             assert((actual_match.get('ipv6', 'IPv6 Not-present') is None) or \
269                    (actual_match.get('tcp6', 'TCP6 Not-present') is None) or \
270                    (actual_match.get('icmp6', 'ICMP6 Not-present') is None) or \
271                    (actual_match.get('sctp6', 'SCTP6 Not-present') is None) or \
272                    (actual_match.get('udp6', 'UDP6 Not-present') is None)), \
273                 'Expected etype %s && actual etype %s=%s' % data
274
275         else:
276             actual_etype = int(actual_match[name], 16)
277
278             assert expected_etype == actual_etype, \
279                 'xml etype: %s && actual etype %s=%s' % data
280
281
282     ETH_COMPARATORS = {
283         'ethernet-type': compare_etype, 
284         'ethernet-source': ethernet_address_comparator,
285         'ethernet-destination': ethernet_address_comparator,
286     }    
287
288     # print 'ethernet_match_comparator-expected_match:', expected_match.toxml()
289     # print 'ethernet_match_comparator-actual_match:', actual_match
290
291     compare_elements(expected_match, actual_match, kw, \
292                      ETH_COMPARATORS, fallback_comparator)
293             
294
295 def ip_subnet_comparator(expected_match, actual_match, kw):
296     # print 'ip_comparator:', expected_match.toxml(), actual_match
297     # print 'ip_comparator-actual_match:', actual_match
298
299     expected_value = expected_match.childNodes[0].data
300     actual_value = actual_match[kw.get(expected_match.nodeName)]
301
302     data = expected_match.toxml(), kw.get(expected_match.nodeName), actual_value
303
304     assert IPNetwork(expected_value) == IPNetwork(actual_value),\
305         'xml part: %s && address %s=%s' % data
306
307
308 def ip_match_comparator(expected_match, actual_match, kw):
309     def compare_proto(child, actual_match, kw):
310         print 'compare_proto:', child.toxml(), actual_match
311         expected_proto = int(child.childNodes[0].data)
312
313         name = child.nodeName
314         data = expected_match.toxml(), name, actual_match
315
316         if expected_proto == 1: # ICMP
317             assert ((actual_match.get('icmp', 'ICMP Not-present') is None) or \
318                     (actual_match.get('icmp6', 'ICMP6 Not-present') is None)), \
319                 'ip protocol type: expected %s, actual %s=%s' % data
320
321         elif expected_proto == 6: # TCP
322             assert ((actual_match.get('tcp', 'TCP Not-present') is None) or \
323                     (actual_match.get('tcp6', 'TCP6 Not-present') is None)), \
324                 'ip protocol type: expected %s, actual %s=%s' % data
325
326         elif expected_proto == 17: #UDP
327             assert ((actual_match.get('udp', 'UDP Not-present') is None) or \
328                     (actual_match.get('udp6', 'UDP6 Not-present') is None)), \
329                 'ip protocol type: expected %s, actual %s=%s' % data
330
331         elif expected_proto == 58: # ICMP
332             assert actual_match.get('icmp6', 'ICMP6 Not-present') is None, \
333                 'ip protocol type: expected %s, actual %s=%s' % data
334
335         elif expected_proto == 132: #SCTP
336             assert ((actual_match.get('sctp', 'SCTP Not-present') is None) or \
337                     (actual_match.get('sctp6', 'SCTP6 Not-present') is None)), \
338                 'ip protocol type: expected %s, actual %s=%s' % data
339
340         else:
341             fallback_comparator(child, actual_match, kw)
342
343
344     def compare_dscp(child, actual_match, kw):
345         # print 'compare_dscp:', child.toxml(), actual_match
346
347         expected_dscp = int(child.childNodes[0].data)
348         name = kw.get(child.nodeName)
349         actual_dscp = int(actual_match[name])
350         
351         data = child.toxml(), name, actual_match
352
353         assert (expected_dscp * 4) == actual_dscp, \
354             'dscp: expected %s, actual %s=%s' % data
355
356
357     IP_MATCH_COMPARATORS = {
358         'ip-protocol': compare_proto, 
359         'ip-dscp': compare_dscp,
360     }    
361
362     # print 'ip_match_comparator:', expected_match.toxml(), actual_match
363     compare_elements(expected_match, actual_match, kw, \
364                      IP_MATCH_COMPARATORS, fallback_comparator)
365
366
367 def match_comparator(expected_match, switch_flow):
368
369     def compare_metadata(expected, actual, kw):
370         masked_value_hex_comparator(expected, actual, kw, \
371                                     'metadata', 'metadata-mask')
372
373     def compare_ipv6_label(expected, actual, kw):
374         print 'compare_ipv6_label', expected.toxml(), actual
375         masked_value_hex_comparator(expected, actual, kw, \
376                                     'ipv6-flabel', 'flabel-mask')
377
378
379     def compare_tunnel_id(expected, actual, kw):
380         masked_value_hex_comparator(expected, actual, kw, \
381                                     'tunnel-id', 'tunnel-mask')
382
383
384     def compare_ipv6_ext_header(expected, actual, kw):
385         masked_value_hex_comparator(expected, actual, kw, \
386                                     'ipv6-exthdr', 'ipv6-exthdr-mask')
387
388
389     MATCH_COMPARATORS = {
390         'arp-source-hardware-address': ethernet_address_comparator,
391         'arp-target-hardware-address': ethernet_address_comparator,
392         'metadata': compare_metadata,
393         'ipv6-label': compare_ipv6_label,
394         'ipv6-ext-header': compare_ipv6_ext_header,
395         'tunnel': compare_tunnel_id,
396         'protocol-match-fields': proto_match_comparator,
397         'vlan-match': proto_match_comparator,
398         'ethernet-match': ethernet_match_comparator,
399         'ip-match': ip_match_comparator,
400         'icmpv4-match': ip_match_comparator,
401         'icmpv6-match': ip_match_comparator,
402         'ipv4-destination': ip_subnet_comparator,
403         'ipv4-source': ip_subnet_comparator,
404         'ipv6-destination': ip_subnet_comparator,
405         'ipv6-source': ip_subnet_comparator,
406     }
407
408     actual_match = switch_flow['matches']
409
410     # print 'match_comparator-expected_match:', expected_match.toxml()
411     # print 'match_comparator-actual_match:', actual_match
412     # print 'match_comparator: keywords', keywords
413
414     compare_elements(expected_match, actual_match, match_keywords, \
415                      MATCH_COMPARATORS, fallback_comparator)
416
417
418 def actions_comparator(actions, switch_flow):
419     # print 'actions_comparator:', actions, switch_flow
420
421     actual_actions = switch_flow['actions'].split(",")
422     # print 'actions_comparator:', actual_actions
423
424     for action in actions.childNodes:
425         if action.nodeType is actions.TEXT_NODE:
426             continue
427
428         action_name = action.childNodes[3].nodeName
429         expected_action = action_keywords.get(action_name)
430
431         data = action.toxml(), expected_action
432         # print 'actions_comparator:', data
433
434         assert expected_action in actual_actions, \
435             'xml part:\n%s\n expected action: %s' % data
436
437
438 def null_comparator(element, switch_flow):
439     pass
440
441
442 def instructions_comparator(instructions_element, switch_flow):
443     INSTRUCTION_COMPARATORS = {
444         'apply-actions': actions_comparator,
445         'default': null_comparator,
446     }
447     # print 'instructions_comparator:', instructions_element, switch_flow
448
449     instructions = instructions_element.childNodes
450
451     for instruction in instructions_element.childNodes:
452         if instruction.nodeType is instructions_element.TEXT_NODE:
453             continue
454         
455         for itype in instruction.childNodes:
456             if itype.nodeType is itype.TEXT_NODE:
457                 continue
458
459             comparator = INSTRUCTION_COMPARATORS.get(itype.nodeName,
460                                         INSTRUCTION_COMPARATORS['default'])
461             comparator(itype, switch_flow)
462
463
464 COMPARATORS = {
465     'cookie': cookie_comparator,
466     'instructions': instructions_comparator,
467     'match': match_comparator,
468     'default': default_comparator,
469 }
470
471 def all_nodes(xml_root):
472     """
473     Generates every non-text nodes.
474     """
475     current_nodes = [xml_root]
476     next_nodes = []
477
478     while len(current_nodes) > 0:
479         for node in current_nodes:
480             if node.nodeType != xml_root.TEXT_NODE:
481                 yield node
482                 next_nodes.extend(node.childNodes)
483
484         current_nodes, next_nodes = next_nodes, []
485
486
487 def check_elements(xmlstr, keywords):
488     # namespace = 'urn:opendaylight:flow:inventory'
489     tree = md.parseString(xmlstr)
490
491     for element in all_nodes(tree.documentElement):
492         # switch flow object contains only some data from xml
493         if element.nodeName not in keywords:
494             # print 'check_elements: element.nodeName', element.nodeName, 'NOT in keywords'
495             continue
496
497         yield element
498
499     raise StopIteration()
500
501
502 class TestOpenFlowXMLs(unittest.TestCase):
503     @classmethod
504     def setUpClass(cls):
505         cls.net = create_network(cls.host, cls.mn_port)
506         cls.net.start()
507         time.sleep(15)
508
509     @classmethod
510     def tearDownClass(cls):
511         cls.net.stop()
512
513
514 def get_values(node, *tags):
515     result = {tag: None for tag in tags}
516     for node in all_nodes(node):
517         if node.nodeName in result and len(node.childNodes) > 0:
518             result[node.nodeName] = node.childNodes[0].nodeValue
519     return result
520     
521     
522 class BadResponseCodeError(Exception):
523     def __init__(self, value):
524         self.value = value
525     def __str__(self):
526         return repr('BadResponseCodeError: %s' % self.value)    
527
528
529 def generate_tests_from_xmls(path, xmls=None):
530     # generate test function from path to request xml
531     def generate_test(path_to_xml):
532         xml_string = ''
533         with open(path_to_xml) as f:
534             xml_string = f.read()
535
536         tree = md.parseString(xml_string)
537         ids = get_values(tree.documentElement, 'table_id', 'id')
538
539         def new_test(self):
540             log = logging.getLogger(__name__)
541             # send request throught RESTCONF
542             data = (self.host, self.port, ids['table_id'], ids['id'])
543             url = 'http://%s:%d/restconf/config/opendaylight-inventory:nodes' \
544                   '/node/openflow:1/table/%s/flow/%s' % data
545             headers = {
546                 'Content-Type': 'application/xml',
547                 'Accept': 'application/xml',
548             }
549             log.info('sending request to url: {}'.format(url))
550             rsp = requests.put(url, auth=('admin', 'admin'), data=xml_string,
551                                headers=headers)                               
552             log.info('received status code: {}'.format(rsp.status_code))
553             log.debug('received content: {}'.format(rsp.text))
554             assert rsp.status_code == 204 or rsp.status_code == 200, 'Status' \
555                     ' code returned %d' % rsp.status_code
556
557             try:        
558                 # check request content against restconf's datastore
559                 response = requests.get(url, auth=('admin', 'admin'),
560                                         headers={'Accept': 'application/xml'})
561                 if response.status_code != 200:
562                     raise BadResponseCodeError('response: {}'.format(response))
563                     
564                 req = xmltodict.parse(ET.tostring(ET.fromstring(xml_string)))
565                 res = xmltodict.parse(ET.tostring(ET.fromstring(response.text)))
566                 assert req == res, 'uploaded and stored xml, are not the same\n' \
567                     'uploaded: %s\nstored:%s' % (req, res)
568
569                 # collect flow table state on switch
570                 switch_flows = get_flows(self.net)
571                 assert len(switch_flows) > 0
572
573                 # compare requested object and flow table state
574                 for important_element in check_elements(xml_string, keywords):
575                     # log.info('important element: {}'.format(important_element.nodeName))
576                     comparator = COMPARATORS.get(important_element.nodeName,
577                                                  COMPARATORS['default'])
578
579                     comparator(important_element, switch_flows[0])                    
580             finally:    
581                 response = requests.delete(url, auth=('admin', 'admin'),
582                                     headers={'Accept': 'application/xml'})
583                 assert response.status_code == 200
584                 print '\n\n\n'
585                 
586         return new_test
587
588     # generate list of available xml requests
589     xmlfiles = None
590     if xmls is not None:
591         xmlfiles = ('f%d.xml' % fid for fid in xmls)
592     else:
593         xmlfiles = (xml for xml in os.listdir(path) if xml.endswith('.xml'))
594
595     # define key getter for sorting
596     def get_test_number(test_name):
597         return int(test_name[1:-4])
598
599     for xmlfile in xmlfiles:
600         test_name = 'test_xml_%04d' % get_test_number(xmlfile)
601         setattr(TestOpenFlowXMLs,
602                 test_name,
603                 generate_test(os.path.join(path, xmlfile)))
604
605
606 if __name__ == '__main__':
607     # set up logging
608     logging.basicConfig(level=logging.DEBUG)
609
610     # parse cmdline arguments
611     parser = argparse.ArgumentParser(description='Run switch <-> ODL tests '
612                                      'defined by xmls.')
613     parser.add_argument('--odlhost', default='127.0.0.1', help='host where '
614                         'odl controller is running')
615     parser.add_argument('--odlport', type=int, default=8080, help='port on '
616                         'which odl\'s RESTCONF is listening')
617     parser.add_argument('--mnport', type=int, default=6653, help='port on '
618                         'which odl\'s controller is listening')
619     parser.add_argument('--xmls', default=None, help='generete tests only '
620                         'from some xmls (i.e. 1,3,34) ')
621     args = parser.parse_args()
622
623     # set host and port of ODL controller for test cases
624     TestOpenFlowXMLs.port = args.odlport
625     TestOpenFlowXMLs.host = args.odlhost
626     TestOpenFlowXMLs.mn_port = args.mnport
627
628     keywords = None
629     with open('keywords.csv') as f:
630         keywords = dict(line.strip().split(';') for line in f
631                         if not line.startswith('#'))
632
633     match_keywords = None
634     with open('match-keywords.csv') as f:
635         match_keywords = dict(line.strip().split(';') for line in f
636                               if not line.startswith('#'))
637
638     action_keywords = None
639     with open('action-keywords.csv') as f:
640         action_keywords = dict(line.strip().split(';') for line in f
641                                     if not line.startswith('#'))
642
643     # fix arguments for unittest
644     del sys.argv[1:]
645
646     # generate tests for TestOpenFlowXMLs
647     if args.xmls is not None:
648         xmls = map(int, args.xmls.split(','))
649         generate_tests_from_xmls('xmls', xmls)
650     else:
651         generate_tests_from_xmls('xmls')
652
653     # run all tests
654     unittest.main()