diff options
Diffstat (limited to 'tests/topotests/lib/topotest.py')
-rw-r--r-- | tests/topotests/lib/topotest.py | 2175 |
1 files changed, 2175 insertions, 0 deletions
diff --git a/tests/topotests/lib/topotest.py b/tests/topotests/lib/topotest.py new file mode 100644 index 0000000..5a3f586 --- /dev/null +++ b/tests/topotests/lib/topotest.py @@ -0,0 +1,2175 @@ +#!/usr/bin/env python + +# +# topotest.py +# Library of helper functions for NetDEF Topology Tests +# +# Copyright (c) 2016 by +# Network Device Education Foundation, Inc. ("NetDEF") +# +# Permission to use, copy, modify, and/or distribute this software +# for any purpose with or without fee is hereby granted, provided +# that the above copyright notice and this permission notice appear +# in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND NETDEF DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL NETDEF BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# + +import difflib +import errno +import functools +import glob +import json +import os +import pdb +import platform +import re +import resource +import signal +import subprocess +import sys +import tempfile +import time +from copy import deepcopy + +import lib.topolog as topolog +from lib.topolog import logger + +if sys.version_info[0] > 2: + import configparser + from collections.abc import Mapping +else: + import ConfigParser as configparser + from collections import Mapping + +from lib import micronet +from lib.micronet_compat import Node + +g_extra_config = {} + + +def get_logs_path(rundir): + logspath = topolog.get_test_logdir() + return os.path.join(rundir, logspath) + + +def gdb_core(obj, daemon, corefiles): + gdbcmds = """ + info threads + bt full + disassemble + up + disassemble + up + disassemble + up + disassemble + up + disassemble + up + disassemble + """ + gdbcmds = [["-ex", i.strip()] for i in gdbcmds.strip().split("\n")] + gdbcmds = [item for sl in gdbcmds for item in sl] + + daemon_path = os.path.join(obj.daemondir, daemon) + backtrace = subprocess.check_output( + ["gdb", daemon_path, corefiles[0], "--batch"] + gdbcmds + ) + sys.stderr.write( + "\n%s: %s crashed. Core file found - Backtrace follows:\n" % (obj.name, daemon) + ) + sys.stderr.write("%s" % backtrace) + return backtrace + + +class json_cmp_result(object): + "json_cmp result class for better assertion messages" + + def __init__(self): + self.errors = [] + + def add_error(self, error): + "Append error message to the result" + for line in error.splitlines(): + self.errors.append(line) + + def has_errors(self): + "Returns True if there were errors, otherwise False." + return len(self.errors) > 0 + + def gen_report(self): + headline = ["Generated JSON diff error report:", ""] + return headline + self.errors + + def __str__(self): + return ( + "Generated JSON diff error report:\n\n\n" + "\n".join(self.errors) + "\n\n" + ) + + +def gen_json_diff_report(d1, d2, exact=False, path="> $", acc=(0, "")): + """ + Internal workhorse which compares two JSON data structures and generates an error report suited to be read by a human eye. + """ + + def dump_json(v): + if isinstance(v, (dict, list)): + return "\t" + "\t".join( + json.dumps(v, indent=4, separators=(",", ": ")).splitlines(True) + ) + else: + return "'{}'".format(v) + + def json_type(v): + if isinstance(v, (list, tuple)): + return "Array" + elif isinstance(v, dict): + return "Object" + elif isinstance(v, (int, float)): + return "Number" + elif isinstance(v, bool): + return "Boolean" + elif isinstance(v, str): + return "String" + elif v == None: + return "null" + + def get_errors(other_acc): + return other_acc[1] + + def get_errors_n(other_acc): + return other_acc[0] + + def add_error(acc, msg, points=1): + return (acc[0] + points, acc[1] + "{}: {}\n".format(path, msg)) + + def merge_errors(acc, other_acc): + return (acc[0] + other_acc[0], acc[1] + other_acc[1]) + + def add_idx(idx): + return "{}[{}]".format(path, idx) + + def add_key(key): + return "{}->{}".format(path, key) + + def has_errors(other_acc): + return other_acc[0] > 0 + + if d2 == "*" or ( + not isinstance(d1, (list, dict)) + and not isinstance(d2, (list, dict)) + and d1 == d2 + ): + return acc + elif ( + not isinstance(d1, (list, dict)) + and not isinstance(d2, (list, dict)) + and d1 != d2 + ): + acc = add_error( + acc, + "d1 has element with value '{}' but in d2 it has value '{}'".format(d1, d2), + ) + elif ( + isinstance(d1, list) + and isinstance(d2, list) + and ((len(d2) > 0 and d2[0] == "__ordered__") or exact) + ): + if not exact: + del d2[0] + if len(d1) != len(d2): + acc = add_error( + acc, + "d1 has Array of length {} but in d2 it is of length {}".format( + len(d1), len(d2) + ), + ) + else: + for idx, v1, v2 in zip(range(0, len(d1)), d1, d2): + acc = merge_errors( + acc, gen_json_diff_report(v1, v2, exact=exact, path=add_idx(idx)) + ) + elif isinstance(d1, list) and isinstance(d2, list): + if len(d1) < len(d2): + acc = add_error( + acc, + "d1 has Array of length {} but in d2 it is of length {}".format( + len(d1), len(d2) + ), + ) + else: + for idx2, v2 in zip(range(0, len(d2)), d2): + found_match = False + closest_diff = None + closest_idx = None + for idx1, v1 in zip(range(0, len(d1)), d1): + tmp_v1 = deepcopy(v1) + tmp_v2 = deepcopy(v2) + tmp_diff = gen_json_diff_report(tmp_v1, tmp_v2, path=add_idx(idx1)) + if not has_errors(tmp_diff): + found_match = True + del d1[idx1] + break + elif not closest_diff or get_errors_n(tmp_diff) < get_errors_n( + closest_diff + ): + closest_diff = tmp_diff + closest_idx = idx1 + if not found_match and isinstance(v2, (list, dict)): + sub_error = "\n\n\t{}".format( + "\t".join(get_errors(closest_diff).splitlines(True)) + ) + acc = add_error( + acc, + ( + "d2 has the following element at index {} which is not present in d1: " + + "\n\n{}\n\n\tClosest match in d1 is at index {} with the following errors: {}" + ).format(idx2, dump_json(v2), closest_idx, sub_error), + ) + if not found_match and not isinstance(v2, (list, dict)): + acc = add_error( + acc, + "d2 has the following element at index {} which is not present in d1: {}".format( + idx2, dump_json(v2) + ), + ) + elif isinstance(d1, dict) and isinstance(d2, dict) and exact: + invalid_keys_d1 = [k for k in d1.keys() if k not in d2.keys()] + invalid_keys_d2 = [k for k in d2.keys() if k not in d1.keys()] + for k in invalid_keys_d1: + acc = add_error(acc, "d1 has key '{}' which is not present in d2".format(k)) + for k in invalid_keys_d2: + acc = add_error(acc, "d2 has key '{}' which is not present in d1".format(k)) + valid_keys_intersection = [k for k in d1.keys() if k in d2.keys()] + for k in valid_keys_intersection: + acc = merge_errors( + acc, gen_json_diff_report(d1[k], d2[k], exact=exact, path=add_key(k)) + ) + elif isinstance(d1, dict) and isinstance(d2, dict): + none_keys = [k for k, v in d2.items() if v == None] + none_keys_present = [k for k in d1.keys() if k in none_keys] + for k in none_keys_present: + acc = add_error( + acc, "d1 has key '{}' which is not supposed to be present".format(k) + ) + keys = [k for k, v in d2.items() if v != None] + invalid_keys_intersection = [k for k in keys if k not in d1.keys()] + for k in invalid_keys_intersection: + acc = add_error(acc, "d2 has key '{}' which is not present in d1".format(k)) + valid_keys_intersection = [k for k in keys if k in d1.keys()] + for k in valid_keys_intersection: + acc = merge_errors( + acc, gen_json_diff_report(d1[k], d2[k], exact=exact, path=add_key(k)) + ) + else: + acc = add_error( + acc, + "d1 has element of type '{}' but the corresponding element in d2 is of type '{}'".format( + json_type(d1), json_type(d2) + ), + points=2, + ) + + return acc + + +def json_cmp(d1, d2, exact=False): + """ + JSON compare function. Receives two parameters: + * `d1`: parsed JSON data structure + * `d2`: parsed JSON data structure + + Returns 'None' when all JSON Object keys and all Array elements of d2 have a match + in d1, i.e., when d2 is a "subset" of d1 without honoring any order. Otherwise an + error report is generated and wrapped in a 'json_cmp_result()'. There are special + parameters and notations explained below which can be used to cover rather unusual + cases: + + * when 'exact is set to 'True' then d1 and d2 are tested for equality (including + order within JSON Arrays) + * using 'null' (or 'None' in Python) as JSON Object value is checking for key + absence in d1 + * using '*' as JSON Object value or Array value is checking for presence in d1 + without checking the values + * using '__ordered__' as first element in a JSON Array in d2 will also check the + order when it is compared to an Array in d1 + """ + + (errors_n, errors) = gen_json_diff_report(deepcopy(d1), deepcopy(d2), exact=exact) + + if errors_n > 0: + result = json_cmp_result() + result.add_error(errors) + return result + else: + return None + + +def router_output_cmp(router, cmd, expected): + """ + Runs `cmd` in router and compares the output with `expected`. + """ + return difflines( + normalize_text(router.vtysh_cmd(cmd)), + normalize_text(expected), + title1="Current output", + title2="Expected output", + ) + + +def router_json_cmp(router, cmd, data, exact=False): + """ + Runs `cmd` that returns JSON data (normally the command ends with 'json') + and compare with `data` contents. + """ + return json_cmp(router.vtysh_cmd(cmd, isjson=True), data, exact) + + +def run_and_expect(func, what, count=20, wait=3): + """ + Run `func` and compare the result with `what`. Do it for `count` times + waiting `wait` seconds between tries. By default it tries 20 times with + 3 seconds delay between tries. + + Returns (True, func-return) on success or + (False, func-return) on failure. + + --- + + Helper functions to use with this function: + - router_output_cmp + - router_json_cmp + """ + start_time = time.time() + func_name = "<unknown>" + if func.__class__ == functools.partial: + func_name = func.func.__name__ + else: + func_name = func.__name__ + + logger.info( + "'{}' polling started (interval {} secs, maximum {} tries)".format( + func_name, wait, count + ) + ) + + while count > 0: + result = func() + if result != what: + time.sleep(wait) + count -= 1 + continue + + end_time = time.time() + logger.info( + "'{}' succeeded after {:.2f} seconds".format( + func_name, end_time - start_time + ) + ) + return (True, result) + + end_time = time.time() + logger.error( + "'{}' failed after {:.2f} seconds".format(func_name, end_time - start_time) + ) + return (False, result) + + +def run_and_expect_type(func, etype, count=20, wait=3, avalue=None): + """ + Run `func` and compare the result with `etype`. Do it for `count` times + waiting `wait` seconds between tries. By default it tries 20 times with + 3 seconds delay between tries. + + This function is used when you want to test the return type and, + optionally, the return value. + + Returns (True, func-return) on success or + (False, func-return) on failure. + """ + start_time = time.time() + func_name = "<unknown>" + if func.__class__ == functools.partial: + func_name = func.func.__name__ + else: + func_name = func.__name__ + + logger.info( + "'{}' polling started (interval {} secs, maximum wait {} secs)".format( + func_name, wait, int(wait * count) + ) + ) + + while count > 0: + result = func() + if not isinstance(result, etype): + logger.debug( + "Expected result type '{}' got '{}' instead".format(etype, type(result)) + ) + time.sleep(wait) + count -= 1 + continue + + if etype != type(None) and avalue != None and result != avalue: + logger.debug("Expected value '{}' got '{}' instead".format(avalue, result)) + time.sleep(wait) + count -= 1 + continue + + end_time = time.time() + logger.info( + "'{}' succeeded after {:.2f} seconds".format( + func_name, end_time - start_time + ) + ) + return (True, result) + + end_time = time.time() + logger.error( + "'{}' failed after {:.2f} seconds".format(func_name, end_time - start_time) + ) + return (False, result) + + +def router_json_cmp_retry(router, cmd, data, exact=False, retry_timeout=10.0): + """ + Runs `cmd` that returns JSON data (normally the command ends with 'json') + and compare with `data` contents. Retry by default for 10 seconds + """ + + def test_func(): + return router_json_cmp(router, cmd, data, exact) + + ok, _ = run_and_expect(test_func, None, int(retry_timeout), 1) + return ok + + +def int2dpid(dpid): + "Converting Integer to DPID" + + try: + dpid = hex(dpid)[2:] + dpid = "0" * (16 - len(dpid)) + dpid + return dpid + except IndexError: + raise Exception( + "Unable to derive default datapath ID - " + "please either specify a dpid or use a " + "canonical switch name such as s23." + ) + + +def pid_exists(pid): + "Check whether pid exists in the current process table." + + if pid <= 0: + return False + try: + os.waitpid(pid, os.WNOHANG) + except: + pass + try: + os.kill(pid, 0) + except OSError as err: + if err.errno == errno.ESRCH: + # ESRCH == No such process + return False + elif err.errno == errno.EPERM: + # EPERM clearly means there's a process to deny access to + return True + else: + # According to "man 2 kill" possible error values are + # (EINVAL, EPERM, ESRCH) + raise + else: + return True + + +def get_textdiff(text1, text2, title1="", title2="", **opts): + "Returns empty string if same or formatted diff" + + diff = "\n".join( + difflib.unified_diff(text1, text2, fromfile=title1, tofile=title2, **opts) + ) + # Clean up line endings + diff = os.linesep.join([s for s in diff.splitlines() if s]) + return diff + + +def difflines(text1, text2, title1="", title2="", **opts): + "Wrapper for get_textdiff to avoid string transformations." + text1 = ("\n".join(text1.rstrip().splitlines()) + "\n").splitlines(1) + text2 = ("\n".join(text2.rstrip().splitlines()) + "\n").splitlines(1) + return get_textdiff(text1, text2, title1, title2, **opts) + + +def get_file(content): + """ + Generates a temporary file in '/tmp' with `content` and returns the file name. + """ + if isinstance(content, list) or isinstance(content, tuple): + content = "\n".join(content) + fde = tempfile.NamedTemporaryFile(mode="w", delete=False) + fname = fde.name + fde.write(content) + fde.close() + return fname + + +def normalize_text(text): + """ + Strips formating spaces/tabs, carriage returns and trailing whitespace. + """ + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\r", "", text) + + # Remove whitespace in the middle of text. + text = re.sub(r"[ \t]+\n", "\n", text) + # Remove whitespace at the end of the text. + text = text.rstrip() + + return text + + +def is_linux(): + """ + Parses unix name output to check if running on GNU/Linux. + + Returns True if running on Linux, returns False otherwise. + """ + + if os.uname()[0] == "Linux": + return True + return False + + +def iproute2_is_vrf_capable(): + """ + Checks if the iproute2 version installed on the system is capable of + handling VRFs by interpreting the output of the 'ip' utility found in PATH. + + Returns True if capability can be detected, returns False otherwise. + """ + + if is_linux(): + try: + subp = subprocess.Popen( + ["ip", "route", "show", "vrf"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + ) + iproute2_err = subp.communicate()[1].splitlines()[0].split()[0] + + if iproute2_err != "Error:": + return True + except Exception: + pass + return False + + +def module_present_linux(module, load): + """ + Returns whether `module` is present. + + If `load` is true, it will try to load it via modprobe. + """ + with open("/proc/modules", "r") as modules_file: + if module.replace("-", "_") in modules_file.read(): + return True + cmd = "/sbin/modprobe {}{}".format("" if load else "-n ", module) + if os.system(cmd) != 0: + return False + else: + return True + + +def module_present_freebsd(module, load): + return True + + +def module_present(module, load=True): + if sys.platform.startswith("linux"): + return module_present_linux(module, load) + elif sys.platform.startswith("freebsd"): + return module_present_freebsd(module, load) + + +def version_cmp(v1, v2): + """ + Compare two version strings and returns: + + * `-1`: if `v1` is less than `v2` + * `0`: if `v1` is equal to `v2` + * `1`: if `v1` is greater than `v2` + + Raises `ValueError` if versions are not well formated. + """ + vregex = r"(?P<whole>\d+(\.(\d+))*)" + v1m = re.match(vregex, v1) + v2m = re.match(vregex, v2) + if v1m is None or v2m is None: + raise ValueError("got a invalid version string") + + # Split values + v1g = v1m.group("whole").split(".") + v2g = v2m.group("whole").split(".") + + # Get the longest version string + vnum = len(v1g) + if len(v2g) > vnum: + vnum = len(v2g) + + # Reverse list because we are going to pop the tail + v1g.reverse() + v2g.reverse() + for _ in range(vnum): + try: + v1n = int(v1g.pop()) + except IndexError: + while v2g: + v2n = int(v2g.pop()) + if v2n > 0: + return -1 + break + + try: + v2n = int(v2g.pop()) + except IndexError: + if v1n > 0: + return 1 + while v1g: + v1n = int(v1g.pop()) + if v1n > 0: + return 1 + break + + if v1n > v2n: + return 1 + if v1n < v2n: + return -1 + return 0 + + +def interface_set_status(node, ifacename, ifaceaction=False, vrf_name=None): + if ifaceaction: + str_ifaceaction = "no shutdown" + else: + str_ifaceaction = "shutdown" + if vrf_name == None: + cmd = 'vtysh -c "configure terminal" -c "interface {0}" -c "{1}"'.format( + ifacename, str_ifaceaction + ) + else: + cmd = ( + 'vtysh -c "configure terminal" -c "interface {0} vrf {1}" -c "{2}"'.format( + ifacename, vrf_name, str_ifaceaction + ) + ) + node.run(cmd) + + +def ip4_route_zebra(node, vrf_name=None): + """ + Gets an output of 'show ip route' command. It can be used + with comparing the output to a reference + """ + if vrf_name == None: + tmp = node.vtysh_cmd("show ip route") + else: + tmp = node.vtysh_cmd("show ip route vrf {0}".format(vrf_name)) + output = re.sub(r" [0-2][0-9]:[0-5][0-9]:[0-5][0-9]", " XX:XX:XX", tmp) + + lines = output.splitlines() + header_found = False + while lines and (not lines[0].strip() or not header_found): + if "o - offload failure" in lines[0]: + header_found = True + lines = lines[1:] + return "\n".join(lines) + + +def ip6_route_zebra(node, vrf_name=None): + """ + Retrieves the output of 'show ipv6 route [vrf vrf_name]', then + canonicalizes it by eliding link-locals. + """ + + if vrf_name == None: + tmp = node.vtysh_cmd("show ipv6 route") + else: + tmp = node.vtysh_cmd("show ipv6 route vrf {0}".format(vrf_name)) + + # Mask out timestamp + output = re.sub(r" [0-2][0-9]:[0-5][0-9]:[0-5][0-9]", " XX:XX:XX", tmp) + + # Mask out the link-local addresses + output = re.sub(r"fe80::[^ ]+,", "fe80::XXXX:XXXX:XXXX:XXXX,", output) + + lines = output.splitlines() + header_found = False + while lines and (not lines[0].strip() or not header_found): + if "o - offload failure" in lines[0]: + header_found = True + lines = lines[1:] + + return "\n".join(lines) + + +def proto_name_to_number(protocol): + return { + "bgp": "186", + "isis": "187", + "ospf": "188", + "rip": "189", + "ripng": "190", + "nhrp": "191", + "eigrp": "192", + "ldp": "193", + "sharp": "194", + "pbr": "195", + "static": "196", + "ospf6": "197", + }.get( + protocol, protocol + ) # default return same as input + + +def ip4_route(node): + """ + Gets a structured return of the command 'ip route'. It can be used in + conjunction with json_cmp() to provide accurate assert explanations. + + Return example: + { + '10.0.1.0/24': { + 'dev': 'eth0', + 'via': '172.16.0.1', + 'proto': '188', + }, + '10.0.2.0/24': { + 'dev': 'eth1', + 'proto': 'kernel', + } + } + """ + output = normalize_text(node.run("ip route")).splitlines() + result = {} + for line in output: + columns = line.split(" ") + route = result[columns[0]] = {} + prev = None + for column in columns: + if prev == "dev": + route["dev"] = column + if prev == "via": + route["via"] = column + if prev == "proto": + # translate protocol names back to numbers + route["proto"] = proto_name_to_number(column) + if prev == "metric": + route["metric"] = column + if prev == "scope": + route["scope"] = column + prev = column + + return result + + +def ip4_vrf_route(node): + """ + Gets a structured return of the command 'ip route show vrf {0}-cust1'. + It can be used in conjunction with json_cmp() to provide accurate assert explanations. + + Return example: + { + '10.0.1.0/24': { + 'dev': 'eth0', + 'via': '172.16.0.1', + 'proto': '188', + }, + '10.0.2.0/24': { + 'dev': 'eth1', + 'proto': 'kernel', + } + } + """ + output = normalize_text( + node.run("ip route show vrf {0}-cust1".format(node.name)) + ).splitlines() + + result = {} + for line in output: + columns = line.split(" ") + route = result[columns[0]] = {} + prev = None + for column in columns: + if prev == "dev": + route["dev"] = column + if prev == "via": + route["via"] = column + if prev == "proto": + # translate protocol names back to numbers + route["proto"] = proto_name_to_number(column) + if prev == "metric": + route["metric"] = column + if prev == "scope": + route["scope"] = column + prev = column + + return result + + +def ip6_route(node): + """ + Gets a structured return of the command 'ip -6 route'. It can be used in + conjunction with json_cmp() to provide accurate assert explanations. + + Return example: + { + '2001:db8:1::/64': { + 'dev': 'eth0', + 'proto': '188', + }, + '2001:db8:2::/64': { + 'dev': 'eth1', + 'proto': 'kernel', + } + } + """ + output = normalize_text(node.run("ip -6 route")).splitlines() + result = {} + for line in output: + columns = line.split(" ") + route = result[columns[0]] = {} + prev = None + for column in columns: + if prev == "dev": + route["dev"] = column + if prev == "via": + route["via"] = column + if prev == "proto": + # translate protocol names back to numbers + route["proto"] = proto_name_to_number(column) + if prev == "metric": + route["metric"] = column + if prev == "pref": + route["pref"] = column + prev = column + + return result + + +def ip6_vrf_route(node): + """ + Gets a structured return of the command 'ip -6 route show vrf {0}-cust1'. + It can be used in conjunction with json_cmp() to provide accurate assert explanations. + + Return example: + { + '2001:db8:1::/64': { + 'dev': 'eth0', + 'proto': '188', + }, + '2001:db8:2::/64': { + 'dev': 'eth1', + 'proto': 'kernel', + } + } + """ + output = normalize_text( + node.run("ip -6 route show vrf {0}-cust1".format(node.name)) + ).splitlines() + result = {} + for line in output: + columns = line.split(" ") + route = result[columns[0]] = {} + prev = None + for column in columns: + if prev == "dev": + route["dev"] = column + if prev == "via": + route["via"] = column + if prev == "proto": + # translate protocol names back to numbers + route["proto"] = proto_name_to_number(column) + if prev == "metric": + route["metric"] = column + if prev == "pref": + route["pref"] = column + prev = column + + return result + + +def ip_rules(node): + """ + Gets a structured return of the command 'ip rule'. It can be used in + conjunction with json_cmp() to provide accurate assert explanations. + + Return example: + [ + { + "pref": "0" + "from": "all" + }, + { + "pref": "32766" + "from": "all" + }, + { + "to": "3.4.5.0/24", + "iif": "r1-eth2", + "pref": "304", + "from": "1.2.0.0/16", + "proto": "zebra" + } + ] + """ + output = normalize_text(node.run("ip rule")).splitlines() + result = [] + for line in output: + columns = line.split(" ") + + route = {} + # remove last character, since it is ':' + pref = columns[0][:-1] + route["pref"] = pref + prev = None + for column in columns: + if prev == "from": + route["from"] = column + if prev == "to": + route["to"] = column + if prev == "proto": + route["proto"] = column + if prev == "iif": + route["iif"] = column + if prev == "fwmark": + route["fwmark"] = column + prev = column + + result.append(route) + return result + + +def sleep(amount, reason=None): + """ + Sleep wrapper that registers in the log the amount of sleep + """ + if reason is None: + logger.info("Sleeping for {} seconds".format(amount)) + else: + logger.info(reason + " ({} seconds)".format(amount)) + + time.sleep(amount) + + +def checkAddressSanitizerError(output, router, component, logdir=""): + "Checks for AddressSanitizer in output. If found, then logs it and returns true, false otherwise" + + def processAddressSanitizerError(asanErrorRe, output, router, component): + sys.stderr.write( + "%s: %s triggered an exception by AddressSanitizer\n" % (router, component) + ) + # Sanitizer Error found in log + pidMark = asanErrorRe.group(1) + addressSanitizerLog = re.search( + "%s(.*)%s" % (pidMark, pidMark), output, re.DOTALL + ) + if addressSanitizerLog: + # Find Calling Test. Could be multiple steps back + testframe = sys._current_frames().values()[0] + level = 0 + while level < 10: + test = os.path.splitext( + os.path.basename(testframe.f_globals["__file__"]) + )[0] + if (test != "topotest") and (test != "topogen"): + # Found the calling test + callingTest = os.path.basename(testframe.f_globals["__file__"]) + break + level = level + 1 + testframe = testframe.f_back + if level >= 10: + # somehow couldn't find the test script. + callingTest = "unknownTest" + # + # Now finding Calling Procedure + level = 0 + while level < 20: + callingProc = sys._getframe(level).f_code.co_name + if ( + (callingProc != "processAddressSanitizerError") + and (callingProc != "checkAddressSanitizerError") + and (callingProc != "checkRouterCores") + and (callingProc != "stopRouter") + and (callingProc != "stop") + and (callingProc != "stop_topology") + and (callingProc != "checkRouterRunning") + and (callingProc != "check_router_running") + and (callingProc != "routers_have_failure") + ): + # Found the calling test + break + level = level + 1 + if level >= 20: + # something wrong - couldn't found the calling test function + callingProc = "unknownProc" + with open("/tmp/AddressSanitzer.txt", "a") as addrSanFile: + sys.stderr.write( + "AddressSanitizer error in topotest `%s`, test `%s`, router `%s`\n\n" + % (callingTest, callingProc, router) + ) + sys.stderr.write( + "\n".join(addressSanitizerLog.group(1).splitlines()) + "\n" + ) + addrSanFile.write("## Error: %s\n\n" % asanErrorRe.group(2)) + addrSanFile.write( + "### AddressSanitizer error in topotest `%s`, test `%s`, router `%s`\n\n" + % (callingTest, callingProc, router) + ) + addrSanFile.write( + " " + + "\n ".join(addressSanitizerLog.group(1).splitlines()) + + "\n" + ) + addrSanFile.write("\n---------------\n") + return + + addressSanitizerError = re.search( + r"(==[0-9]+==)ERROR: AddressSanitizer: ([^\s]*) ", output + ) + if addressSanitizerError: + processAddressSanitizerError(addressSanitizerError, output, router, component) + return True + + # No Address Sanitizer Error in Output. Now check for AddressSanitizer daemon file + if logdir: + filepattern = logdir + "/" + router + "/" + component + ".asan.*" + logger.debug( + "Log check for %s on %s, pattern %s\n" % (component, router, filepattern) + ) + for file in glob.glob(filepattern): + with open(file, "r") as asanErrorFile: + asanError = asanErrorFile.read() + addressSanitizerError = re.search( + r"(==[0-9]+==)ERROR: AddressSanitizer: ([^\s]*) ", asanError + ) + if addressSanitizerError: + processAddressSanitizerError( + addressSanitizerError, asanError, router, component + ) + return True + return False + + +def _sysctl_atleast(commander, variable, min_value): + if isinstance(min_value, tuple): + min_value = list(min_value) + is_list = isinstance(min_value, list) + + sval = commander.cmd_raises("sysctl -n " + variable).strip() + if is_list: + cur_val = [int(x) for x in sval.split()] + else: + cur_val = int(sval) + + set_value = False + if is_list: + for i, v in enumerate(cur_val): + if v < min_value[i]: + set_value = True + else: + min_value[i] = v + else: + if cur_val < min_value: + set_value = True + if set_value: + if is_list: + valstr = " ".join([str(x) for x in min_value]) + else: + valstr = str(min_value) + logger.info("Increasing sysctl %s from %s to %s", variable, cur_val, valstr) + commander.cmd_raises('sysctl -w {}="{}"\n'.format(variable, valstr)) + + +def _sysctl_assure(commander, variable, value): + if isinstance(value, tuple): + value = list(value) + is_list = isinstance(value, list) + + sval = commander.cmd_raises("sysctl -n " + variable).strip() + if is_list: + cur_val = [int(x) for x in sval.split()] + else: + cur_val = sval + + set_value = False + if is_list: + for i, v in enumerate(cur_val): + if v != value[i]: + set_value = True + else: + value[i] = v + else: + if cur_val != str(value): + set_value = True + + if set_value: + if is_list: + valstr = " ".join([str(x) for x in value]) + else: + valstr = str(value) + logger.info("Changing sysctl %s from %s to %s", variable, cur_val, valstr) + commander.cmd_raises('sysctl -w {}="{}"\n'.format(variable, valstr)) + + +def sysctl_atleast(commander, variable, min_value, raises=False): + try: + if commander is None: + commander = micronet.Commander("topotest") + return _sysctl_atleast(commander, variable, min_value) + except subprocess.CalledProcessError as error: + logger.warning( + "%s: Failed to assure sysctl min value %s = %s", + commander, + variable, + min_value, + ) + if raises: + raise + + +def sysctl_assure(commander, variable, value, raises=False): + try: + if commander is None: + commander = micronet.Commander("topotest") + return _sysctl_assure(commander, variable, value) + except subprocess.CalledProcessError as error: + logger.warning( + "%s: Failed to assure sysctl value %s = %s", + commander, + variable, + value, + exc_info=True, + ) + if raises: + raise + + +def rlimit_atleast(rname, min_value, raises=False): + try: + cval = resource.getrlimit(rname) + soft, hard = cval + if soft < min_value: + nval = (min_value, hard if min_value < hard else min_value) + logger.info("Increasing rlimit %s from %s to %s", rname, cval, nval) + resource.setrlimit(rname, nval) + except subprocess.CalledProcessError as error: + logger.warning( + "Failed to assure rlimit [%s] = %s", rname, min_value, exc_info=True + ) + if raises: + raise + + +def fix_netns_limits(ns): + + # Maximum read and write socket buffer sizes + sysctl_atleast(ns, "net.ipv4.tcp_rmem", [10 * 1024, 87380, 16 * 2 ** 20]) + sysctl_atleast(ns, "net.ipv4.tcp_wmem", [10 * 1024, 87380, 16 * 2 ** 20]) + + sysctl_assure(ns, "net.ipv4.conf.all.rp_filter", 0) + sysctl_assure(ns, "net.ipv4.conf.default.rp_filter", 0) + sysctl_assure(ns, "net.ipv4.conf.lo.rp_filter", 0) + + sysctl_assure(ns, "net.ipv4.conf.all.forwarding", 1) + sysctl_assure(ns, "net.ipv4.conf.default.forwarding", 1) + + # XXX if things fail look here as this wasn't done previously + sysctl_assure(ns, "net.ipv6.conf.all.forwarding", 1) + sysctl_assure(ns, "net.ipv6.conf.default.forwarding", 1) + + # ARP + sysctl_assure(ns, "net.ipv4.conf.default.arp_announce", 2) + sysctl_assure(ns, "net.ipv4.conf.default.arp_notify", 1) + # Setting this to 1 breaks topotests that rely on lo addresses being proxy arp'd for + sysctl_assure(ns, "net.ipv4.conf.default.arp_ignore", 0) + sysctl_assure(ns, "net.ipv4.conf.all.arp_announce", 2) + sysctl_assure(ns, "net.ipv4.conf.all.arp_notify", 1) + # Setting this to 1 breaks topotests that rely on lo addresses being proxy arp'd for + sysctl_assure(ns, "net.ipv4.conf.all.arp_ignore", 0) + + sysctl_assure(ns, "net.ipv4.icmp_errors_use_inbound_ifaddr", 1) + + # Keep ipv6 permanent addresses on an admin down + sysctl_assure(ns, "net.ipv6.conf.all.keep_addr_on_down", 1) + if version_cmp(platform.release(), "4.20") >= 0: + sysctl_assure(ns, "net.ipv6.route.skip_notify_on_dev_down", 1) + + sysctl_assure(ns, "net.ipv4.conf.all.ignore_routes_with_linkdown", 1) + sysctl_assure(ns, "net.ipv6.conf.all.ignore_routes_with_linkdown", 1) + + # igmp + sysctl_atleast(ns, "net.ipv4.igmp_max_memberships", 1000) + + # Use neigh information on selection of nexthop for multipath hops + sysctl_assure(ns, "net.ipv4.fib_multipath_use_neigh", 1) + + +def fix_host_limits(): + """Increase system limits.""" + + rlimit_atleast(resource.RLIMIT_NPROC, 8 * 1024) + rlimit_atleast(resource.RLIMIT_NOFILE, 16 * 1024) + sysctl_atleast(None, "fs.file-max", 16 * 1024) + sysctl_atleast(None, "kernel.pty.max", 16 * 1024) + + # Enable coredumps + # Original on ubuntu 17.x, but apport won't save as in namespace + # |/usr/share/apport/apport %p %s %c %d %P + sysctl_assure(None, "kernel.core_pattern", "%e_core-sig_%s-pid_%p.dmp") + sysctl_assure(None, "kernel.core_uses_pid", 1) + sysctl_assure(None, "fs.suid_dumpable", 1) + + # Maximum connection backlog + sysctl_atleast(None, "net.core.netdev_max_backlog", 4 * 1024) + + # Maximum read and write socket buffer sizes + sysctl_atleast(None, "net.core.rmem_max", 16 * 2 ** 20) + sysctl_atleast(None, "net.core.wmem_max", 16 * 2 ** 20) + + # Garbage Collection Settings for ARP and Neighbors + sysctl_atleast(None, "net.ipv4.neigh.default.gc_thresh2", 4 * 1024) + sysctl_atleast(None, "net.ipv4.neigh.default.gc_thresh3", 8 * 1024) + sysctl_atleast(None, "net.ipv6.neigh.default.gc_thresh2", 4 * 1024) + sysctl_atleast(None, "net.ipv6.neigh.default.gc_thresh3", 8 * 1024) + # Hold entries for 10 minutes + sysctl_assure(None, "net.ipv4.neigh.default.base_reachable_time_ms", 10 * 60 * 1000) + sysctl_assure(None, "net.ipv6.neigh.default.base_reachable_time_ms", 10 * 60 * 1000) + + # igmp + sysctl_assure(None, "net.ipv4.neigh.default.mcast_solicit", 10) + + # MLD + sysctl_atleast(None, "net.ipv6.mld_max_msf", 512) + + # Increase routing table size to 128K + sysctl_atleast(None, "net.ipv4.route.max_size", 128 * 1024) + sysctl_atleast(None, "net.ipv6.route.max_size", 128 * 1024) + + +def setup_node_tmpdir(logdir, name): + # Cleanup old log, valgrind, and core files. + subprocess.check_call( + "rm -rf {0}/{1}.valgrind.* {1}.*.asan {0}/{1}/".format(logdir, name), shell=True + ) + + # Setup the per node directory. + nodelogdir = "{}/{}".format(logdir, name) + subprocess.check_call( + "mkdir -p {0} && chmod 1777 {0}".format(nodelogdir), shell=True + ) + logfile = "{0}/{1}.log".format(logdir, name) + return logfile + + +class Router(Node): + "A Node with IPv4/IPv6 forwarding enabled" + + def __init__(self, name, **params): + + # Backward compatibility: + # Load configuration defaults like topogen. + self.config_defaults = configparser.ConfigParser( + defaults={ + "verbosity": "info", + "frrdir": "/usr/lib/frr", + "routertype": "frr", + "memleak_path": "", + } + ) + + self.config_defaults.read( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "../pytest.ini") + ) + + # If this topology is using old API and doesn't have logdir + # specified, then attempt to generate an unique logdir. + self.logdir = params.get("logdir") + if self.logdir is None: + self.logdir = get_logs_path(g_extra_config["rundir"]) + + if not params.get("logger"): + # If logger is present topogen has already set this up + logfile = setup_node_tmpdir(self.logdir, name) + l = topolog.get_logger(name, log_level="debug", target=logfile) + params["logger"] = l + + super(Router, self).__init__(name, **params) + + self.daemondir = None + self.hasmpls = False + self.routertype = "frr" + self.unified_config = None + self.daemons = { + "zebra": 0, + "ripd": 0, + "ripngd": 0, + "ospfd": 0, + "ospf6d": 0, + "isisd": 0, + "bgpd": 0, + "pimd": 0, + "pim6d": 0, + "ldpd": 0, + "eigrpd": 0, + "nhrpd": 0, + "staticd": 0, + "bfdd": 0, + "sharpd": 0, + "babeld": 0, + "pbrd": 0, + "pathd": 0, + "snmpd": 0, + } + self.daemons_options = {"zebra": ""} + self.reportCores = True + self.version = None + + self.ns_cmd = "sudo nsenter -a -t {} ".format(self.pid) + try: + # Allow escaping from running inside docker + cgroup = open("/proc/1/cgroup").read() + m = re.search("[0-9]+:cpuset:/docker/([a-f0-9]+)", cgroup) + if m: + self.ns_cmd = "docker exec -it {} ".format(m.group(1)) + self.ns_cmd + except IOError: + pass + else: + logger.debug("CMD to enter {}: {}".format(self.name, self.ns_cmd)) + + def _config_frr(self, **params): + "Configure FRR binaries" + self.daemondir = params.get("frrdir") + if self.daemondir is None: + self.daemondir = self.config_defaults.get("topogen", "frrdir") + + zebra_path = os.path.join(self.daemondir, "zebra") + if not os.path.isfile(zebra_path): + raise Exception("FRR zebra binary doesn't exist at {}".format(zebra_path)) + + # pylint: disable=W0221 + # Some params are only meaningful for the parent class. + def config(self, **params): + super(Router, self).config(**params) + + # User did not specify the daemons directory, try to autodetect it. + self.daemondir = params.get("daemondir") + if self.daemondir is None: + self.routertype = params.get( + "routertype", self.config_defaults.get("topogen", "routertype") + ) + self._config_frr(**params) + else: + # Test the provided path + zpath = os.path.join(self.daemondir, "zebra") + if not os.path.isfile(zpath): + raise Exception("No zebra binary found in {}".format(zpath)) + # Allow user to specify routertype when the path was specified. + if params.get("routertype") is not None: + self.routertype = params.get("routertype") + + # Set ownership of config files + self.cmd("chown {0}:{0}vty /etc/{0}".format(self.routertype)) + + def terminate(self): + # Stop running FRR daemons + self.stopRouter() + super(Router, self).terminate() + os.system("chmod -R go+rw " + self.logdir) + + # Return count of running daemons + def listDaemons(self): + ret = [] + rc, stdout, _ = self.cmd_status( + "ls -1 /var/run/%s/*.pid" % self.routertype, warn=False + ) + if rc: + return ret + for d in stdout.strip().split("\n"): + pidfile = d.strip() + try: + pid = int(self.cmd_raises("cat %s" % pidfile, warn=False).strip()) + name = os.path.basename(pidfile[:-4]) + + # probably not compatible with bsd. + rc, _, _ = self.cmd_status("test -d /proc/{}".format(pid), warn=False) + if rc: + logger.warning( + "%s: %s exited leaving pidfile %s (%s)", + self.name, + name, + pidfile, + pid, + ) + self.cmd("rm -- " + pidfile) + else: + ret.append((name, pid)) + except (subprocess.CalledProcessError, ValueError): + pass + return ret + + def stopRouter(self, assertOnError=True, minErrorVersion="5.1"): + # Stop Running FRR Daemons + running = self.listDaemons() + if not running: + return "" + + logger.info("%s: stopping %s", self.name, ", ".join([x[0] for x in running])) + for name, pid in running: + logger.info("{}: sending SIGTERM to {}".format(self.name, name)) + try: + os.kill(pid, signal.SIGTERM) + except OSError as err: + logger.info( + "%s: could not kill %s (%s): %s", self.name, name, pid, str(err) + ) + + running = self.listDaemons() + if running: + for _ in range(0, 30): + sleep( + 0.5, + "{}: waiting for daemons stopping: {}".format( + self.name, ", ".join([x[0] for x in running]) + ), + ) + running = self.listDaemons() + if not running: + break + + if not running: + return "" + + logger.warning( + "%s: sending SIGBUS to: %s", self.name, ", ".join([x[0] for x in running]) + ) + for name, pid in running: + pidfile = "/var/run/{}/{}.pid".format(self.routertype, name) + logger.info("%s: killing %s", self.name, name) + self.cmd("kill -SIGBUS %d" % pid) + self.cmd("rm -- " + pidfile) + + sleep( + 0.5, "%s: waiting for daemons to exit/core after initial SIGBUS" % self.name + ) + + errors = self.checkRouterCores(reportOnce=True) + if self.checkRouterVersion("<", minErrorVersion): + # ignore errors in old versions + errors = "" + if assertOnError and (errors is not None) and len(errors) > 0: + assert "Errors found - details follow:" == 0, errors + return errors + + def removeIPs(self): + for interface in self.intfNames(): + try: + self.intf_ip_cmd(interface, "ip address flush " + interface) + except Exception as ex: + logger.error("%s can't remove IPs %s", self, str(ex)) + # pdb.set_trace() + # assert False, "can't remove IPs %s" % str(ex) + + def checkCapability(self, daemon, param): + if param is not None: + daemon_path = os.path.join(self.daemondir, daemon) + daemon_search_option = param.replace("-", "") + output = self.cmd( + "{0} -h | grep {1}".format(daemon_path, daemon_search_option) + ) + if daemon_search_option not in output: + return False + return True + + def loadConf(self, daemon, source=None, param=None): + """Enabled and set config for a daemon. + + Arranges for loading of daemon configuration from the specified source. Possible + `source` values are `None` for an empty config file, a path name which is used + directly, or a file name with no path components which is first looked for + directly and then looked for under a sub-directory named after router. + """ + + # Unfortunately this API allowsfor source to not exist for any and all routers. + if source: + head, tail = os.path.split(source) + if not head and not self.path_exists(tail): + script_dir = os.environ["PYTEST_TOPOTEST_SCRIPTDIR"] + router_relative = os.path.join(script_dir, self.name, tail) + if self.path_exists(router_relative): + source = router_relative + self.logger.info( + "using router relative configuration: {}".format(source) + ) + + # print "Daemons before:", self.daemons + if daemon in self.daemons.keys() or daemon == "frr": + if daemon == "frr": + self.unified_config = 1 + else: + self.daemons[daemon] = 1 + if param is not None: + self.daemons_options[daemon] = param + conf_file = "/etc/{}/{}.conf".format(self.routertype, daemon) + if source is None or not os.path.exists(source): + if daemon == "frr" or not self.unified_config: + self.cmd_raises("rm -f " + conf_file) + self.cmd_raises("touch " + conf_file) + else: + self.cmd_raises("cp {} {}".format(source, conf_file)) + + if not self.unified_config or daemon == "frr": + self.cmd_raises("chown {0}:{0} {1}".format(self.routertype, conf_file)) + self.cmd_raises("chmod 664 {}".format(conf_file)) + + if (daemon == "snmpd") and (self.routertype == "frr"): + # /etc/snmp is private mount now + self.cmd('echo "agentXSocket /etc/frr/agentx" >> /etc/snmp/frr.conf') + self.cmd('echo "mibs +ALL" > /etc/snmp/snmp.conf') + + if (daemon == "zebra") and (self.daemons["staticd"] == 0): + # Add staticd with zebra - if it exists + try: + staticd_path = os.path.join(self.daemondir, "staticd") + except: + pdb.set_trace() + + if os.path.isfile(staticd_path): + self.daemons["staticd"] = 1 + self.daemons_options["staticd"] = "" + # Auto-Started staticd has no config, so it will read from zebra config + else: + logger.info("No daemon {} known".format(daemon)) + # print "Daemons after:", self.daemons + + def runInWindow(self, cmd, title=None): + return self.run_in_window(cmd, title) + + def startRouter(self, tgen=None): + if self.unified_config: + self.cmd( + 'echo "service integrated-vtysh-config" >> /etc/%s/vtysh.conf' + % self.routertype + ) + else: + # Disable integrated-vtysh-config + self.cmd( + 'echo "no service integrated-vtysh-config" >> /etc/%s/vtysh.conf' + % self.routertype + ) + + self.cmd( + "chown %s:%svty /etc/%s/vtysh.conf" + % (self.routertype, self.routertype, self.routertype) + ) + # TODO remove the following lines after all tests are migrated to Topogen. + # Try to find relevant old logfiles in /tmp and delete them + map(os.remove, glob.glob("{}/{}/*.log".format(self.logdir, self.name))) + # Remove old core files + map(os.remove, glob.glob("{}/{}/*.dmp".format(self.logdir, self.name))) + # Remove IP addresses from OS first - we have them in zebra.conf + self.removeIPs() + # If ldp is used, check for LDP to be compiled and Linux Kernel to be 4.5 or higher + # No error - but return message and skip all the tests + if self.daemons["ldpd"] == 1: + ldpd_path = os.path.join(self.daemondir, "ldpd") + if not os.path.isfile(ldpd_path): + logger.info("LDP Test, but no ldpd compiled or installed") + return "LDP Test, but no ldpd compiled or installed" + + if version_cmp(platform.release(), "4.5") < 0: + logger.info("LDP Test need Linux Kernel 4.5 minimum") + return "LDP Test need Linux Kernel 4.5 minimum" + # Check if have mpls + if tgen != None: + self.hasmpls = tgen.hasmpls + if self.hasmpls != True: + logger.info( + "LDP/MPLS Tests will be skipped, platform missing module(s)" + ) + else: + # Test for MPLS Kernel modules available + self.hasmpls = False + if not module_present("mpls-router"): + logger.info( + "MPLS tests will not run (missing mpls-router kernel module)" + ) + elif not module_present("mpls-iptunnel"): + logger.info( + "MPLS tests will not run (missing mpls-iptunnel kernel module)" + ) + else: + self.hasmpls = True + if self.hasmpls != True: + return "LDP/MPLS Tests need mpls kernel modules" + + # Really want to use sysctl_atleast here, but only when MPLS is actually being + # used + self.cmd("echo 100000 > /proc/sys/net/mpls/platform_labels") + + shell_routers = g_extra_config["shell"] + if "all" in shell_routers or self.name in shell_routers: + self.run_in_window(os.getenv("SHELL", "bash"), title="sh-%s" % self.name) + + if self.daemons["eigrpd"] == 1: + eigrpd_path = os.path.join(self.daemondir, "eigrpd") + if not os.path.isfile(eigrpd_path): + logger.info("EIGRP Test, but no eigrpd compiled or installed") + return "EIGRP Test, but no eigrpd compiled or installed" + + if self.daemons["bfdd"] == 1: + bfdd_path = os.path.join(self.daemondir, "bfdd") + if not os.path.isfile(bfdd_path): + logger.info("BFD Test, but no bfdd compiled or installed") + return "BFD Test, but no bfdd compiled or installed" + + status = self.startRouterDaemons(tgen=tgen) + + vtysh_routers = g_extra_config["vtysh"] + if "all" in vtysh_routers or self.name in vtysh_routers: + self.run_in_window("vtysh", title="vt-%s" % self.name) + + if self.unified_config: + self.cmd("vtysh -f /etc/frr/frr.conf") + + return status + + def getStdErr(self, daemon): + return self.getLog("err", daemon) + + def getStdOut(self, daemon): + return self.getLog("out", daemon) + + def getLog(self, log, daemon): + return self.cmd("cat {}/{}/{}.{}".format(self.logdir, self.name, daemon, log)) + + def startRouterDaemons(self, daemons=None, tgen=None): + "Starts FRR daemons for this router." + + asan_abort = g_extra_config["asan_abort"] + gdb_breakpoints = g_extra_config["gdb_breakpoints"] + gdb_daemons = g_extra_config["gdb_daemons"] + gdb_routers = g_extra_config["gdb_routers"] + valgrind_extra = g_extra_config["valgrind_extra"] + valgrind_memleaks = g_extra_config["valgrind_memleaks"] + strace_daemons = g_extra_config["strace_daemons"] + + # Get global bundle data + if not self.path_exists("/etc/frr/support_bundle_commands.conf"): + # Copy global value if was covered by namespace mount + bundle_data = "" + if os.path.exists("/etc/frr/support_bundle_commands.conf"): + with open("/etc/frr/support_bundle_commands.conf", "r") as rf: + bundle_data = rf.read() + self.cmd_raises( + "cat > /etc/frr/support_bundle_commands.conf", + stdin=bundle_data, + ) + + # Starts actual daemons without init (ie restart) + # cd to per node directory + self.cmd("install -m 775 -o frr -g frr -d {}/{}".format(self.logdir, self.name)) + self.set_cwd("{}/{}".format(self.logdir, self.name)) + self.cmd("umask 000") + + # Re-enable to allow for report per run + self.reportCores = True + + # XXX: glue code forward ported from removed function. + if self.version == None: + self.version = self.cmd( + os.path.join(self.daemondir, "bgpd") + " -v" + ).split()[2] + logger.info("{}: running version: {}".format(self.name, self.version)) + # If `daemons` was specified then some upper API called us with + # specific daemons, otherwise just use our own configuration. + daemons_list = [] + if daemons is not None: + daemons_list = daemons + else: + # Append all daemons configured. + for daemon in self.daemons: + if self.daemons[daemon] == 1: + daemons_list.append(daemon) + + def start_daemon(daemon, extra_opts=None): + daemon_opts = self.daemons_options.get(daemon, "") + rediropt = " > {0}.out 2> {0}.err".format(daemon) + if daemon == "snmpd": + binary = "/usr/sbin/snmpd" + cmdenv = "" + cmdopt = "{} -C -c /etc/frr/snmpd.conf -p ".format( + daemon_opts + ) + "/var/run/{}/snmpd.pid -x /etc/frr/agentx".format(self.routertype) + else: + binary = os.path.join(self.daemondir, daemon) + + cmdenv = "ASAN_OPTIONS=" + if asan_abort: + cmdenv = "abort_on_error=1:" + cmdenv += "log_path={0}/{1}.{2}.asan ".format( + self.logdir, self.name, daemon + ) + + if valgrind_memleaks: + this_dir = os.path.dirname( + os.path.abspath(os.path.realpath(__file__)) + ) + supp_file = os.path.abspath( + os.path.join(this_dir, "../../../tools/valgrind.supp") + ) + cmdenv += " /usr/bin/valgrind --num-callers=50 --log-file={1}/{2}.valgrind.{0}.%p --leak-check=full --suppressions={3}".format( + daemon, self.logdir, self.name, supp_file + ) + if valgrind_extra: + cmdenv += ( + " --gen-suppressions=all --expensive-definedness-checks=yes" + ) + elif daemon in strace_daemons or "all" in strace_daemons: + cmdenv = "strace -f -D -o {1}/{2}.strace.{0} ".format( + daemon, self.logdir, self.name + ) + + cmdopt = "{} --command-log-always --log file:{}.log --log-level debug".format( + daemon_opts, daemon + ) + if extra_opts: + cmdopt += " " + extra_opts + + if ( + (gdb_routers or gdb_daemons) + and ( + not gdb_routers or self.name in gdb_routers or "all" in gdb_routers + ) + and (not gdb_daemons or daemon in gdb_daemons or "all" in gdb_daemons) + ): + if daemon == "snmpd": + cmdopt += " -f " + + cmdopt += rediropt + gdbcmd = "sudo -E gdb " + binary + if gdb_breakpoints: + gdbcmd += " -ex 'set breakpoint pending on'" + for bp in gdb_breakpoints: + gdbcmd += " -ex 'b {}'".format(bp) + gdbcmd += " -ex 'run {}'".format(cmdopt) + + self.run_in_window(gdbcmd, daemon) + + logger.info( + "%s: %s %s launched in gdb window", self, self.routertype, daemon + ) + else: + if daemon != "snmpd": + cmdopt += " -d " + cmdopt += rediropt + + try: + self.cmd_raises(" ".join([cmdenv, binary, cmdopt]), warn=False) + except subprocess.CalledProcessError as error: + self.logger.error( + '%s: Failed to launch "%s" daemon (%d) using: %s%s%s:', + self, + daemon, + error.returncode, + error.cmd, + '\n:stdout: "{}"'.format(error.stdout.strip()) + if error.stdout + else "", + '\n:stderr: "{}"'.format(error.stderr.strip()) + if error.stderr + else "", + ) + else: + logger.info("%s: %s %s started", self, self.routertype, daemon) + + # Start Zebra first + if "zebra" in daemons_list: + start_daemon("zebra", "-s 90000000") + while "zebra" in daemons_list: + daemons_list.remove("zebra") + + # Start staticd next if required + if "staticd" in daemons_list: + start_daemon("staticd") + while "staticd" in daemons_list: + daemons_list.remove("staticd") + + if "snmpd" in daemons_list: + # Give zerbra a chance to configure interface addresses that snmpd daemon + # may then use. + time.sleep(2) + + start_daemon("snmpd") + while "snmpd" in daemons_list: + daemons_list.remove("snmpd") + + if daemons is None: + # Fix Link-Local Addresses on initial startup + # Somehow (on Mininet only), Zebra removes the IPv6 Link-Local addresses on start. Fix this + _, output, _ = self.cmd_status( + "for i in `ls /sys/class/net/` ; do mac=`cat /sys/class/net/$i/address`; echo $i: $mac; [ -z \"$mac\" ] && continue; 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", + stderr=subprocess.STDOUT, + ) + logger.debug("Set MACs:\n%s", output) + + # Now start all the other daemons + for daemon in daemons_list: + if self.daemons[daemon] == 0: + continue + start_daemon(daemon) + + # Check if daemons are running. + rundaemons = self.cmd("ls -1 /var/run/%s/*.pid" % self.routertype) + if re.search(r"No such file or directory", rundaemons): + return "Daemons are not running" + + # Update the permissions on the log files + self.cmd("chown frr:frr -R {}/{}".format(self.logdir, self.name)) + self.cmd("chmod ug+rwX,o+r -R {}/{}".format(self.logdir, self.name)) + + return "" + + def killRouterDaemons( + self, daemons, wait=True, assertOnError=True, minErrorVersion="5.1" + ): + # Kill Running FRR + # Daemons(user specified daemon only) using SIGKILL + rundaemons = self.cmd("ls -1 /var/run/%s/*.pid" % self.routertype) + errors = "" + daemonsNotRunning = [] + if re.search(r"No such file or directory", rundaemons): + return errors + for daemon in daemons: + if rundaemons is not None and daemon in rundaemons: + numRunning = 0 + dmns = rundaemons.split("\n") + # Exclude empty string at end of list + for d in dmns[:-1]: + if re.search(r"%s" % daemon, d): + daemonpid = self.cmd("cat %s" % d.rstrip()).rstrip() + if daemonpid.isdigit() and pid_exists(int(daemonpid)): + logger.info( + "{}: killing {}".format( + self.name, + os.path.basename(d.rstrip().rsplit(".", 1)[0]), + ) + ) + self.cmd("kill -9 %s" % daemonpid) + if pid_exists(int(daemonpid)): + numRunning += 1 + while wait and numRunning > 0: + sleep( + 2, + "{}: waiting for {} daemon to be stopped".format( + self.name, daemon + ), + ) + + # 2nd round of kill if daemons didn't exit + for d in dmns[:-1]: + if re.search(r"%s" % daemon, d): + daemonpid = self.cmd("cat %s" % d.rstrip()).rstrip() + if daemonpid.isdigit() and pid_exists( + int(daemonpid) + ): + logger.info( + "{}: killing {}".format( + self.name, + os.path.basename( + d.rstrip().rsplit(".", 1)[0] + ), + ) + ) + self.cmd("kill -9 %s" % daemonpid) + if daemonpid.isdigit() and not pid_exists( + int(daemonpid) + ): + numRunning -= 1 + self.cmd("rm -- {}".format(d.rstrip())) + if wait: + errors = self.checkRouterCores(reportOnce=True) + if self.checkRouterVersion("<", minErrorVersion): + # ignore errors in old versions + errors = "" + if assertOnError and len(errors) > 0: + assert "Errors found - details follow:" == 0, errors + else: + daemonsNotRunning.append(daemon) + if len(daemonsNotRunning) > 0: + errors = errors + "Daemons are not running", daemonsNotRunning + + return errors + + def checkRouterCores(self, reportLeaks=True, reportOnce=False): + if reportOnce and not self.reportCores: + return + reportMade = False + traces = "" + for daemon in self.daemons: + if self.daemons[daemon] == 1: + # Look for core file + corefiles = glob.glob( + "{}/{}/{}_core*.dmp".format(self.logdir, self.name, daemon) + ) + if len(corefiles) > 0: + backtrace = gdb_core(self, daemon, corefiles) + traces = ( + traces + + "\n%s: %s crashed. Core file found - Backtrace follows:\n%s" + % (self.name, daemon, backtrace) + ) + reportMade = True + elif reportLeaks: + log = self.getStdErr(daemon) + if "memstats" in log: + sys.stderr.write( + "%s: %s has memory leaks:\n" % (self.name, daemon) + ) + traces = traces + "\n%s: %s has memory leaks:\n" % ( + self.name, + daemon, + ) + log = re.sub("core_handler: ", "", log) + log = re.sub( + r"(showing active allocations in memory group [a-zA-Z0-9]+)", + r"\n ## \1", + log, + ) + log = re.sub("memstats: ", " ", log) + sys.stderr.write(log) + reportMade = True + # Look for AddressSanitizer Errors and append to /tmp/AddressSanitzer.txt if found + if checkAddressSanitizerError( + self.getStdErr(daemon), self.name, daemon, self.logdir + ): + sys.stderr.write( + "%s: Daemon %s killed by AddressSanitizer" % (self.name, daemon) + ) + traces = traces + "\n%s: Daemon %s killed by AddressSanitizer" % ( + self.name, + daemon, + ) + reportMade = True + if reportMade: + self.reportCores = False + return traces + + def checkRouterRunning(self): + "Check if router daemons are running and collect crashinfo they don't run" + + global fatal_error + + daemonsRunning = self.cmd( + 'vtysh -c "show logging" | grep "Logging configuration for"' + ) + # Look for AddressSanitizer Errors in vtysh output and append to /tmp/AddressSanitzer.txt if found + if checkAddressSanitizerError(daemonsRunning, self.name, "vtysh"): + return "%s: vtysh killed by AddressSanitizer" % (self.name) + + for daemon in self.daemons: + if daemon == "snmpd": + continue + if (self.daemons[daemon] == 1) and not (daemon in daemonsRunning): + sys.stderr.write("%s: Daemon %s not running\n" % (self.name, daemon)) + if daemon == "staticd": + sys.stderr.write( + "You may have a copy of staticd installed but are attempting to test against\n" + ) + sys.stderr.write( + "a version of FRR that does not have staticd, please cleanup the install dir\n" + ) + + # Look for core file + corefiles = glob.glob( + "{}/{}/{}_core*.dmp".format(self.logdir, self.name, daemon) + ) + if len(corefiles) > 0: + gdb_core(self, daemon, corefiles) + else: + # No core found - If we find matching logfile in /tmp, then print last 20 lines from it. + if os.path.isfile( + "{}/{}/{}.log".format(self.logdir, self.name, daemon) + ): + log_tail = subprocess.check_output( + [ + "tail -n20 {}/{}/{}.log 2> /dev/null".format( + self.logdir, self.name, daemon + ) + ], + shell=True, + ) + sys.stderr.write( + "\nFrom %s %s %s log file:\n" + % (self.routertype, self.name, daemon) + ) + sys.stderr.write("%s\n" % log_tail) + + # Look for AddressSanitizer Errors and append to /tmp/AddressSanitzer.txt if found + if checkAddressSanitizerError( + self.getStdErr(daemon), self.name, daemon, self.logdir + ): + return "%s: Daemon %s not running - killed by AddressSanitizer" % ( + self.name, + daemon, + ) + + return "%s: Daemon %s not running" % (self.name, daemon) + return "" + + def checkRouterVersion(self, cmpop, version): + """ + Compares router version using operation `cmpop` with `version`. + Valid `cmpop` values: + * `>=`: has the same version or greater + * '>': has greater version + * '=': has the same version + * '<': has a lesser version + * '<=': has the same version or lesser + + Usage example: router.checkRouterVersion('>', '1.0') + """ + + # Make sure we have version information first + if self.version == None: + self.version = self.cmd( + os.path.join(self.daemondir, "bgpd") + " -v" + ).split()[2] + logger.info("{}: running version: {}".format(self.name, self.version)) + + rversion = self.version + if rversion == None: + return False + + result = version_cmp(rversion, version) + if cmpop == ">=": + return result >= 0 + if cmpop == ">": + return result > 0 + if cmpop == "=": + return result == 0 + if cmpop == "<": + return result < 0 + if cmpop == "<": + return result < 0 + if cmpop == "<=": + return result <= 0 + + def get_ipv6_linklocal(self): + "Get LinkLocal Addresses from interfaces" + + linklocal = [] + + ifaces = self.cmd("ip -6 address") + # Fix newlines (make them all the same) + ifaces = ("\n".join(ifaces.splitlines()) + "\n").splitlines() + interface = "" + ll_per_if_count = 0 + for line in ifaces: + m = re.search("[0-9]+: ([^:@]+)[-@a-z0-9:]+ <", line) + if m: + interface = m.group(1) + ll_per_if_count = 0 + m = re.search( + "inet6 (fe80::[0-9a-f]+:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+)[/0-9]* scope link", + line, + ) + if m: + local = m.group(1) + ll_per_if_count += 1 + if ll_per_if_count > 1: + linklocal += [["%s-%s" % (interface, ll_per_if_count), local]] + else: + linklocal += [[interface, local]] + return linklocal + + def daemon_available(self, daemon): + "Check if specified daemon is installed (and for ldp if kernel supports MPLS)" + + daemon_path = os.path.join(self.daemondir, daemon) + if not os.path.isfile(daemon_path): + return False + if daemon == "ldpd": + if version_cmp(platform.release(), "4.5") < 0: + return False + if not module_present("mpls-router", load=False): + return False + if not module_present("mpls-iptunnel", load=False): + return False + return True + + def get_routertype(self): + "Return the type of Router (frr)" + + return self.routertype + + def report_memory_leaks(self, filename_prefix, testscript): + "Report Memory Leaks to file prefixed with given string" + + leakfound = False + filename = filename_prefix + re.sub(r"\.py", "", testscript) + ".txt" + for daemon in self.daemons: + if self.daemons[daemon] == 1: + log = self.getStdErr(daemon) + if "memstats" in log: + # Found memory leak + logger.info( + "\nRouter {} {} StdErr Log:\n{}".format(self.name, daemon, log) + ) + if not leakfound: + leakfound = True + # Check if file already exists + fileexists = os.path.isfile(filename) + leakfile = open(filename, "a") + if not fileexists: + # New file - add header + leakfile.write( + "# Memory Leak Detection for topotest %s\n\n" + % testscript + ) + leakfile.write("## Router %s\n" % self.name) + leakfile.write("### Process %s\n" % daemon) + log = re.sub("core_handler: ", "", log) + log = re.sub( + r"(showing active allocations in memory group [a-zA-Z0-9]+)", + r"\n#### \1\n", + log, + ) + log = re.sub("memstats: ", " ", log) + leakfile.write(log) + leakfile.write("\n") + if leakfound: + leakfile.close() + + +def frr_unicode(s): + """Convert string to unicode, depending on python version""" + if sys.version_info[0] > 2: + return s + else: + return unicode(s) # pylint: disable=E0602 + + +def is_mapping(o): + return isinstance(o, Mapping) |