]> git.proxmox.com Git - mirror_frr.git/blob - tests/topotests/lib/topotest.py
topotest: add text normalization function
[mirror_frr.git] / tests / topotests / lib / topotest.py
1 #!/usr/bin/env python
2
3 #
4 # topotest.py
5 # Library of helper functions for NetDEF Topology Tests
6 #
7 # Copyright (c) 2016 by
8 # Network Device Education Foundation, Inc. ("NetDEF")
9 #
10 # Permission to use, copy, modify, and/or distribute this software
11 # for any purpose with or without fee is hereby granted, provided
12 # that the above copyright notice and this permission notice appear
13 # in all copies.
14 #
15 # THE SOFTWARE IS PROVIDED "AS IS" AND NETDEF DISCLAIMS ALL WARRANTIES
16 # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
17 # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NETDEF BE LIABLE FOR
18 # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
19 # DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
20 # WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
21 # ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
22 # OF THIS SOFTWARE.
23 #
24
25 import os
26 import errno
27 import re
28 import sys
29 import glob
30 import StringIO
31 import subprocess
32 import tempfile
33 import platform
34 import difflib
35
36 from lib.topolog import logger
37
38 from mininet.topo import Topo
39 from mininet.net import Mininet
40 from mininet.node import Node, OVSSwitch, Host
41 from mininet.log import setLogLevel, info
42 from mininet.cli import CLI
43 from mininet.link import Intf
44
45 from time import sleep
46
47 class json_cmp_result(object):
48 "json_cmp result class for better assertion messages"
49
50 def __init__(self):
51 self.errors = []
52
53 def add_error(self, error):
54 "Append error message to the result"
55 self.errors.append(error)
56
57 def has_errors(self):
58 "Returns True if there were errors, otherwise False."
59 return len(self.errors) > 0
60
61
62 def json_cmp(d1, d2, reason=False):
63 """
64 JSON compare function. Receives two parameters:
65 * `d1`: json value
66 * `d2`: json subset which we expect
67
68 Returns `None` when all keys that `d1` has matches `d2`,
69 otherwise a string containing what failed.
70
71 Note: key absence can be tested by adding a key with value `None`.
72 """
73 squeue = [(d1, d2, 'json')]
74 result = json_cmp_result()
75 for s in squeue:
76 nd1, nd2, parent = s
77 s1, s2 = set(nd1), set(nd2)
78
79 # Expect all required fields to exist.
80 s2_req = set([key for key in nd2 if nd2[key] is not None])
81 diff = s2_req - s1
82 if diff != set({}):
83 result.add_error('expected key(s) {} in {} (have {})'.format(
84 str(list(diff)), parent, str(list(s1))))
85
86 for key in s2.intersection(s1):
87 # Test for non existence of key in d2
88 if nd2[key] is None:
89 result.add_error('"{}" should not exist in {} (have {})'.format(
90 key, parent, str(s1)))
91 continue
92 # If nd1 key is a dict, we have to recurse in it later.
93 if isinstance(nd2[key], type({})):
94 if not isinstance(nd1[key], type({})):
95 result.add_error(
96 '{}["{}"] has different type than expected '.format(parent, key) +
97 '(have {}, expected {})'.format(type(nd1[key]), type(nd2[key])))
98 continue
99 nparent = '{}["{}"]'.format(parent, key)
100 squeue.append((nd1[key], nd2[key], nparent))
101 continue
102 # Check list items
103 if isinstance(nd2[key], type([])):
104 if not isinstance(nd1[key], type([])):
105 result.add_error(
106 '{}["{}"] has different type than expected '.format(parent, key) +
107 '(have {}, expected {})'.format(type(nd1[key]), type(nd2[key])))
108 continue
109 # Check list size
110 if len(nd2[key]) > len(nd1[key]):
111 result.add_error(
112 '{}["{}"] too few items '.format(parent, key) +
113 '(have ({}) "{}", expected ({}) "{}")'.format(
114 len(nd1[key]), str(nd1[key]), len(nd2[key]), str(nd2[key])))
115 continue
116
117 # List all unmatched items errors
118 unmatched = []
119 for expected in nd2[key]:
120 matched = False
121 for value in nd1[key]:
122 if json_cmp({'json': value}, {'json': expected}) is None:
123 matched = True
124 break
125
126 if matched:
127 break
128 if not matched:
129 unmatched.append(expected)
130
131 # If there are unmatched items, error out.
132 if unmatched:
133 result.add_error(
134 '{}["{}"] value is different (have "{}", expected "{}")'.format(
135 parent, key, str(nd1[key]), str(nd2[key])))
136 continue
137
138 # Compare JSON values
139 if nd1[key] != nd2[key]:
140 result.add_error(
141 '{}["{}"] value is different (have "{}", expected "{}")'.format(
142 parent, key, str(nd1[key]), str(nd2[key])))
143 continue
144
145 if result.has_errors():
146 return result
147
148 return None
149
150 def run_and_expect(func, what, count=20, wait=3):
151 """
152 Run `func` and compare the result with `what`. Do it for `count` times
153 waiting `wait` seconds between tries. By default it tries 20 times with
154 3 seconds delay between tries.
155
156 Returns (True, func-return) on success or
157 (False, func-return) on failure.
158 """
159 while count > 0:
160 result = func()
161 if result != what:
162 sleep(wait)
163 count -= 1
164 continue
165 return (True, result)
166 return (False, result)
167
168
169 def int2dpid(dpid):
170 "Converting Integer to DPID"
171
172 try:
173 dpid = hex(dpid)[2:]
174 dpid = '0'*(16-len(dpid))+dpid
175 return dpid
176 except IndexError:
177 raise Exception('Unable to derive default datapath ID - '
178 'please either specify a dpid or use a '
179 'canonical switch name such as s23.')
180
181 def pid_exists(pid):
182 "Check whether pid exists in the current process table."
183
184 if pid <= 0:
185 return False
186 try:
187 os.kill(pid, 0)
188 except OSError as err:
189 if err.errno == errno.ESRCH:
190 # ESRCH == No such process
191 return False
192 elif err.errno == errno.EPERM:
193 # EPERM clearly means there's a process to deny access to
194 return True
195 else:
196 # According to "man 2 kill" possible error values are
197 # (EINVAL, EPERM, ESRCH)
198 raise
199 else:
200 return True
201
202 def get_textdiff(text1, text2, title1="", title2=""):
203 "Returns empty string if same or formatted diff"
204
205 diff = '\n'.join(difflib.context_diff(text1, text2,
206 fromfile=title1, tofile=title2))
207 # Clean up line endings
208 diff = os.linesep.join([s for s in diff.splitlines() if s])
209 return diff
210
211 def difflines(text1, text2, title1='', title2=''):
212 "Wrapper for get_textdiff to avoid string transformations."
213 text1 = ('\n'.join(text1.rstrip().splitlines()) + '\n').splitlines(1)
214 text2 = ('\n'.join(text2.rstrip().splitlines()) + '\n').splitlines(1)
215 return get_textdiff(text1, text2, title1, title2)
216
217 def get_file(content):
218 """
219 Generates a temporary file in '/tmp' with `content` and returns the file name.
220 """
221 fde = tempfile.NamedTemporaryFile(mode='w', delete=False)
222 fname = fde.name
223 fde.write(content)
224 fde.close()
225 return fname
226
227 def normalize_text(text):
228 """
229 Strips formating spaces/tabs and carriage returns.
230 """
231 text = re.sub(r'[ \t]+', ' ', text)
232 text = re.sub(r'\r', '', text)
233 return text
234
235 def checkAddressSanitizerError(output, router, component):
236 "Checks for AddressSanitizer in output. If found, then logs it and returns true, false otherwise"
237
238 addressSantizerError = re.search('(==[0-9]+==)ERROR: AddressSanitizer: ([^\s]*) ', output)
239 if addressSantizerError:
240 sys.stderr.write("%s: %s triggered an exception by AddressSanitizer\n" % (router, component))
241 # Sanitizer Error found in log
242 pidMark = addressSantizerError.group(1)
243 addressSantizerLog = re.search('%s(.*)%s' % (pidMark, pidMark), output, re.DOTALL)
244 if addressSantizerLog:
245 callingTest = os.path.basename(sys._current_frames().values()[0].f_back.f_back.f_globals['__file__'])
246 callingProc = sys._getframe(2).f_code.co_name
247 with open("/tmp/AddressSanitzer.txt", "a") as addrSanFile:
248 sys.stderr.write('\n'.join(addressSantizerLog.group(1).splitlines()) + '\n')
249 addrSanFile.write("## Error: %s\n\n" % addressSantizerError.group(2))
250 addrSanFile.write("### AddressSanitizer error in topotest `%s`, test `%s`, router `%s`\n\n" % (callingTest, callingProc, router))
251 addrSanFile.write(' '+ '\n '.join(addressSantizerLog.group(1).splitlines()) + '\n')
252 addrSanFile.write("\n---------------\n")
253 return True
254 return False
255
256 def addRouter(topo, name):
257 "Adding a FRRouter (or Quagga) to Topology"
258
259 MyPrivateDirs = ['/etc/frr',
260 '/etc/quagga',
261 '/var/run/frr',
262 '/var/run/quagga',
263 '/var/log']
264 return topo.addNode(name, cls=Router, privateDirs=MyPrivateDirs)
265
266 def set_sysctl(node, sysctl, value):
267 "Set a sysctl value and return None on success or an error string"
268 valuestr = '{}'.format(value)
269 command = "sysctl {0}={1}".format(sysctl, valuestr)
270 cmdret = node.cmd(command)
271
272 matches = re.search(r'([^ ]+) = ([^\s]+)', cmdret)
273 if matches is None:
274 return cmdret
275 if matches.group(1) != sysctl:
276 return cmdret
277 if matches.group(2) != valuestr:
278 return cmdret
279
280 return None
281
282 def assert_sysctl(node, sysctl, value):
283 "Set and assert that the sysctl is set with the specified value."
284 assert set_sysctl(node, sysctl, value) is None
285
286 class LinuxRouter(Node):
287 "A Node with IPv4/IPv6 forwarding enabled."
288
289 def config(self, **params):
290 super(LinuxRouter, self).config(**params)
291 # Enable forwarding on the router
292 assert_sysctl(self, 'net.ipv4.ip_forward', 1)
293 assert_sysctl(self, 'net.ipv6.conf.all.forwarding', 1)
294 def terminate(self):
295 """
296 Terminate generic LinuxRouter Mininet instance
297 """
298 set_sysctl(self, 'net.ipv4.ip_forward', 0)
299 set_sysctl(self, 'net.ipv6.conf.all.forwarding', 0)
300 super(LinuxRouter, self).terminate()
301
302 class Router(Node):
303 "A Node with IPv4/IPv6 forwarding enabled and Quagga as Routing Engine"
304
305 def __init__(self, name, **params):
306 super(Router, self).__init__(name, **params)
307 self.logdir = params.get('logdir', '/tmp')
308 self.daemondir = None
309 self.routertype = 'frr'
310 self.daemons = {'zebra': 0, 'ripd': 0, 'ripngd': 0, 'ospfd': 0,
311 'ospf6d': 0, 'isisd': 0, 'bgpd': 0, 'pimd': 0,
312 'ldpd': 0}
313
314 def _config_frr(self, **params):
315 "Configure FRR binaries"
316 self.daemondir = params.get('frrdir')
317 if self.daemondir is None:
318 self.daemondir = '/usr/lib/frr'
319
320 zebra_path = os.path.join(self.daemondir, 'zebra')
321 if not os.path.isfile(zebra_path):
322 raise Exception("FRR zebra binary doesn't exist at {}".format(zebra_path))
323
324 def _config_quagga(self, **params):
325 "Configure Quagga binaries"
326 self.daemondir = params.get('quaggadir')
327 if self.daemondir is None:
328 self.daemondir = '/usr/lib/quagga'
329
330 zebra_path = os.path.join(self.daemondir, 'zebra')
331 if not os.path.isfile(zebra_path):
332 raise Exception("Quagga zebra binary doesn't exist at {}".format(zebra_path))
333
334 # pylint: disable=W0221
335 # Some params are only meaningful for the parent class.
336 def config(self, **params):
337 super(Router, self).config(**params)
338
339 # User did not specify the daemons directory, try to autodetect it.
340 self.daemondir = params.get('daemondir')
341 if self.daemondir is None:
342 self.routertype = params.get('routertype', 'frr')
343 if self.routertype == 'quagga':
344 self._config_quagga(**params)
345 else:
346 self._config_frr(**params)
347 else:
348 # Test the provided path
349 zpath = os.path.join(self.daemondir, 'zebra')
350 if not os.path.isfile(zpath):
351 raise Exception('No zebra binary found in {}'.format(zpath))
352 # Allow user to specify routertype when the path was specified.
353 if params.get('routertype') is not None:
354 self.routertype = self.params.get('routertype')
355
356 # Enable forwarding on the router
357 assert_sysctl(self, 'net.ipv4.ip_forward', 1)
358 assert_sysctl(self, 'net.ipv6.conf.all.forwarding', 1)
359 # Enable coredumps
360 assert_sysctl(self, 'kernel.core_uses_pid', 1)
361 assert_sysctl(self, 'fs.suid_dumpable', 2)
362 corefile = '{}/{}_%e_core-sig_%s-pid_%p.dmp'.format(self.logdir, self.name)
363 assert_sysctl(self, 'kernel.core_pattern', corefile)
364 self.cmd('ulimit -c unlimited')
365 # Set ownership of config files
366 self.cmd('chown {0}:{0}vty /etc/{0}'.format(self.routertype))
367
368 def terminate(self):
369 # Delete Running Quagga or FRR Daemons
370 self.stopRouter()
371 # rundaemons = self.cmd('ls -1 /var/run/%s/*.pid' % self.routertype)
372 # for d in StringIO.StringIO(rundaemons):
373 # self.cmd('kill -7 `cat %s`' % d.rstrip())
374 # self.waitOutput()
375 # Disable forwarding
376 set_sysctl(self, 'net.ipv4.ip_forward', 0)
377 set_sysctl(self, 'net.ipv6.conf.all.forwarding', 0)
378 super(Router, self).terminate()
379 def stopRouter(self):
380 # Stop Running Quagga or FRR Daemons
381 rundaemons = self.cmd('ls -1 /var/run/%s/*.pid' % self.routertype)
382 if rundaemons is not None:
383 for d in StringIO.StringIO(rundaemons):
384 daemonpid = self.cmd('cat %s' % d.rstrip()).rstrip()
385 if (daemonpid.isdigit() and pid_exists(int(daemonpid))):
386 self.cmd('kill -7 %s' % daemonpid)
387 self.waitOutput()
388 def removeIPs(self):
389 for interface in self.intfNames():
390 self.cmd('ip address flush', interface)
391 def loadConf(self, daemon, source=None):
392 # print "Daemons before:", self.daemons
393 if daemon in self.daemons.keys():
394 self.daemons[daemon] = 1
395 if source is None:
396 self.cmd('touch /etc/%s/%s.conf' % (self.routertype, daemon))
397 self.waitOutput()
398 else:
399 self.cmd('cp %s /etc/%s/%s.conf' % (source, self.routertype, daemon))
400 self.waitOutput()
401 self.cmd('chmod 640 /etc/%s/%s.conf' % (self.routertype, daemon))
402 self.waitOutput()
403 self.cmd('chown %s:%s /etc/%s/%s.conf' % (self.routertype, self.routertype, self.routertype, daemon))
404 self.waitOutput()
405 else:
406 logger.warning('No daemon {} known'.format(daemon))
407 # print "Daemons after:", self.daemons
408 def startRouter(self):
409 # Disable integrated-vtysh-config
410 self.cmd('echo "no service integrated-vtysh-config" >> /etc/%s/vtysh.conf' % self.routertype)
411 self.cmd('chown %s:%svty /etc/%s/vtysh.conf' % (self.routertype, self.routertype, self.routertype))
412 # TODO remove the following lines after all tests are migrated to Topogen.
413 # Try to find relevant old logfiles in /tmp and delete them
414 map(os.remove, glob.glob("/tmp/*%s*.log" % self.name))
415 # Remove old core files
416 map(os.remove, glob.glob("/tmp/%s*.dmp" % self.name))
417 # Remove IP addresses from OS first - we have them in zebra.conf
418 self.removeIPs()
419 # If ldp is used, check for LDP to be compiled and Linux Kernel to be 4.5 or higher
420 # No error - but return message and skip all the tests
421 if self.daemons['ldpd'] == 1:
422 ldpd_path = os.path.join(self.daemondir, 'ldpd')
423 if not os.path.isfile(ldpd_path):
424 logger.warning("LDP Test, but no ldpd compiled or installed")
425 return "LDP Test, but no ldpd compiled or installed"
426 kernel_version = re.search(r'([0-9]+)\.([0-9]+).*', platform.release())
427
428 if kernel_version:
429 if (float(kernel_version.group(1)) < 4 or
430 (float(kernel_version.group(1)) == 4 and float(kernel_version.group(2)) < 5)):
431 logger.warning("LDP Test need Linux Kernel 4.5 minimum")
432 return "LDP Test need Linux Kernel 4.5 minimum"
433
434 self.cmd('/sbin/modprobe mpls-router')
435 self.cmd('/sbin/modprobe mpls-iptunnel')
436 self.cmd('echo 100000 > /proc/sys/net/mpls/platform_labels')
437 # Init done - now restarting daemons
438 self.restartRouter()
439 return ""
440 def restartRouter(self):
441 # Starts actuall daemons without init (ie restart)
442 # Start Zebra first
443 if self.daemons['zebra'] == 1:
444 zebra_path = os.path.join(self.daemondir, 'zebra')
445 self.cmd('{0} > {1}/{2}-zebra.out 2> {1}/{2}-zebra.err &'.format(
446 zebra_path, self.logdir, self.name
447 ))
448 self.waitOutput()
449 logger.debug('{}: {} zebra started'.format(self, self.routertype))
450 sleep(1)
451 # Fix Link-Local Addresses
452 # Somehow (on Mininet only), Zebra removes the IPv6 Link-Local addresses on start. Fix this
453 self.cmd('for i in `ls /sys/class/net/` ; do mac=`cat /sys/class/net/$i/address`; IFS=\':\'; set $mac; unset IFS; ip address add dev $i scope link fe80::$(printf %02x $((0x$1 ^ 2)))$2:${3}ff:fe$4:$5$6/64; done')
454 # Now start all the other daemons
455 for daemon in self.daemons:
456 # Skip disabled daemons and zebra
457 if self.daemons[daemon] == 0 or daemon == 'zebra':
458 continue
459
460 daemon_path = os.path.join(self.daemondir, daemon)
461 self.cmd('{0} > {1}/{2}-{3}.out 2> {1}/{2}-{3}.err &'.format(
462 daemon_path, self.logdir, self.name, daemon
463 ))
464 self.waitOutput()
465 logger.debug('{}: {} {} started'.format(self, self.routertype, daemon))
466 def getStdErr(self, daemon):
467 return self.getLog('err', daemon)
468 def getStdOut(self, daemon):
469 return self.getLog('out', daemon)
470 def getLog(self, log, daemon):
471 return self.cmd('cat {}/{}-{}.{}'.format(self.logdir, self.name, daemon, log))
472 def checkRouterRunning(self):
473 "Check if router daemons are running and collect crashinfo they don't run"
474
475 global fatal_error
476
477 daemonsRunning = self.cmd('vtysh -c "show log" | grep "Logging configuration for"')
478 # Look for AddressSanitizer Errors in vtysh output and append to /tmp/AddressSanitzer.txt if found
479 if checkAddressSanitizerError(daemonsRunning, self.name, "vtysh"):
480 return "%s: vtysh killed by AddressSanitizer" % (self.name)
481
482 for daemon in self.daemons:
483 if (self.daemons[daemon] == 1) and not (daemon in daemonsRunning):
484 sys.stderr.write("%s: Daemon %s not running\n" % (self.name, daemon))
485 # Look for core file
486 corefiles = glob.glob('{}/{}_{}_core*.dmp'.format(
487 self.logdir, self.name, daemon))
488 if (len(corefiles) > 0):
489 daemon_path = os.path.join(self.daemondir, daemon)
490 backtrace = subprocess.check_output([
491 "gdb {} {} --batch -ex bt 2> /dev/null".format(daemon_path, corefiles[0])
492 ], shell=True)
493 sys.stderr.write("\n%s: %s crashed. Core file found - Backtrace follows:\n" % (self.name, daemon))
494 sys.stderr.write("%s\n" % backtrace)
495 else:
496 # No core found - If we find matching logfile in /tmp, then print last 20 lines from it.
497 if os.path.isfile('{}/{}-{}.log'.format(self.logdir, self.name, daemon)):
498 log_tail = subprocess.check_output([
499 "tail -n20 {}/{}-{}.log 2> /dev/null".format(
500 self.logdir, self.name, daemon)
501 ], shell=True)
502 sys.stderr.write("\nFrom %s %s %s log file:\n" % (self.routertype, self.name, daemon))
503 sys.stderr.write("%s\n" % log_tail)
504
505 # Look for AddressSanitizer Errors and append to /tmp/AddressSanitzer.txt if found
506 if checkAddressSanitizerError(self.getStdErr(daemon), self.name, daemon):
507 return "%s: Daemon %s not running - killed by AddressSanitizer" % (self.name, daemon)
508
509 return "%s: Daemon %s not running" % (self.name, daemon)
510 return ""
511 def get_ipv6_linklocal(self):
512 "Get LinkLocal Addresses from interfaces"
513
514 linklocal = []
515
516 ifaces = self.cmd('ip -6 address')
517 # Fix newlines (make them all the same)
518 ifaces = ('\n'.join(ifaces.splitlines()) + '\n').splitlines()
519 interface=""
520 ll_per_if_count=0
521 for line in ifaces:
522 m = re.search('[0-9]+: ([^:@]+)[@if0-9:]+ <', line)
523 if m:
524 interface = m.group(1)
525 ll_per_if_count = 0
526 m = re.search('inet6 (fe80::[0-9a-f]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+)[/0-9]* scope link', line)
527 if m:
528 local = m.group(1)
529 ll_per_if_count += 1
530 if (ll_per_if_count > 1):
531 linklocal += [["%s-%s" % (interface, ll_per_if_count), local]]
532 else:
533 linklocal += [[interface, local]]
534 return linklocal
535 def daemon_available(self, daemon):
536 "Check if specified daemon is installed (and for ldp if kernel supports MPLS)"
537
538 daemon_path = os.path.join(self.daemondir, daemon)
539 if not os.path.isfile(daemon_path):
540 return False
541 if (daemon == 'ldpd'):
542 kernel_version = re.search(r'([0-9]+)\.([0-9]+).*', platform.release())
543 if kernel_version:
544 if (float(kernel_version.group(1)) < 4 or
545 (float(kernel_version.group(1)) == 4 and float(kernel_version.group(2)) < 5)):
546 return False
547 else:
548 return False
549 return True
550 def get_routertype(self):
551 "Return the type of Router (frr or quagga)"
552
553 return self.routertype
554 def report_memory_leaks(self, filename_prefix, testscript):
555 "Report Memory Leaks to file prefixed with given string"
556
557 leakfound = False
558 filename = filename_prefix + re.sub(r"\.py", "", testscript) + ".txt"
559 for daemon in self.daemons:
560 if (self.daemons[daemon] == 1):
561 log = self.getStdErr(daemon)
562 if "memstats" in log:
563 # Found memory leak
564 logger.info('\nRouter {} {} StdErr Log:\n{}'.format(
565 self.name, daemon, log))
566 if not leakfound:
567 leakfound = True
568 # Check if file already exists
569 fileexists = os.path.isfile(filename)
570 leakfile = open(filename, "a")
571 if not fileexists:
572 # New file - add header
573 leakfile.write("# Memory Leak Detection for topotest %s\n\n" % testscript)
574 leakfile.write("## Router %s\n" % self.name)
575 leakfile.write("### Process %s\n" % daemon)
576 log = re.sub("core_handler: ", "", log)
577 log = re.sub(r"(showing active allocations in memory group [a-zA-Z0-9]+)", r"\n#### \1\n", log)
578 log = re.sub("memstats: ", " ", log)
579 leakfile.write(log)
580 leakfile.write("\n")
581 if leakfound:
582 leakfile.close()
583
584
585 class LegacySwitch(OVSSwitch):
586 "A Legacy Switch without OpenFlow"
587
588 def __init__(self, name, **params):
589 OVSSwitch.__init__(self, name, failMode='standalone', **params)
590 self.switchIP = None