diff options
Diffstat (limited to 'tests/topotests/lib/topogen.py')
-rw-r--r-- | tests/topotests/lib/topogen.py | 1338 |
1 files changed, 1338 insertions, 0 deletions
diff --git a/tests/topotests/lib/topogen.py b/tests/topotests/lib/topogen.py new file mode 100644 index 0000000..04712ed --- /dev/null +++ b/tests/topotests/lib/topogen.py @@ -0,0 +1,1338 @@ +# +# topogen.py +# Library of helper functions for NetDEF Topology Tests +# +# Copyright (c) 2017 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. +# + +""" +Topogen (Topology Generator) is an abstraction around Topotest and Mininet to +help reduce boilerplate code and provide a stable interface to build topology +tests on. + +Basic usage instructions: + +* Define a Topology class with a build method using mininet.topo.Topo. + See examples/test_template.py. +* Use Topogen inside the build() method with get_topogen. + e.g. get_topogen(self). +* Start up your topology with: Topogen(YourTopology) +* Initialize the Mininet with your topology with: tgen.start_topology() +* Configure your routers/hosts and start them +* Run your tests / mininet cli. +* After running stop Mininet with: tgen.stop_topology() +""" + +import grp +import inspect +import json +import logging +import os +import platform +import pwd +import re +import subprocess +import sys +from collections import OrderedDict + +if sys.version_info[0] > 2: + import configparser +else: + import ConfigParser as configparser + +import lib.topolog as topolog +from lib.micronet import Commander +from lib.micronet_compat import Mininet +from lib.topolog import logger +from lib.topotest import g_extra_config + +from lib import topotest + +CWD = os.path.dirname(os.path.realpath(__file__)) + +# pylint: disable=C0103 +# Global Topogen variable. This is being used to keep the Topogen available on +# all test functions without declaring a test local variable. +global_tgen = None + + +def get_topogen(topo=None): + """ + Helper function to retrieve Topogen. Must be called with `topo` when called + inside the build() method of Topology class. + """ + if topo is not None: + global_tgen.topo = topo + return global_tgen + + +def set_topogen(tgen): + "Helper function to set Topogen" + # pylint: disable=W0603 + global global_tgen + global_tgen = tgen + + +def is_string(value): + """Return True if value is a string.""" + try: + return isinstance(value, basestring) # type: ignore + except NameError: + return isinstance(value, str) + + +def get_exabgp_cmd(commander=None): + """Return the command to use for ExaBGP version < 4.""" + + if commander is None: + commander = Commander("topogen") + + def exacmd_version_ok(exacmd): + logger.debug("checking %s for exabgp < version 4", exacmd) + _, stdout, _ = commander.cmd_status(exacmd + " -v", warn=False) + m = re.search(r"ExaBGP\s*:\s*((\d+)\.(\d+)(?:\.(\d+))?)", stdout) + if not m: + return False + version = m.group(1) + if topotest.version_cmp(version, "4") >= 0: + logging.debug("found exabgp version >= 4 in %s will keep looking", exacmd) + return False + logger.info("Using ExaBGP version %s in %s", version, exacmd) + return True + + exacmd = commander.get_exec_path("exabgp") + if exacmd and exacmd_version_ok(exacmd): + return exacmd + py2_path = commander.get_exec_path("python2") + if py2_path: + exacmd = py2_path + " -m exabgp" + if exacmd_version_ok(exacmd): + return exacmd + py2_path = commander.get_exec_path("python") + if py2_path: + exacmd = py2_path + " -m exabgp" + if exacmd_version_ok(exacmd): + return exacmd + return None + + +# +# Main class: topology builder +# + +# Topogen configuration defaults +tgen_defaults = { + "verbosity": "info", + "frrdir": "/usr/lib/frr", + "routertype": "frr", + "memleak_path": "", +} + + +class Topogen(object): + "A topology test builder helper." + + CONFIG_SECTION = "topogen" + + def __init__(self, topodef, modname="unnamed"): + """ + Topogen initialization function, takes the following arguments: + * `cls`: OLD:uthe topology class that is child of mininet.topo or a build function. + * `topodef`: A dictionary defining the topology, a filename of a json file, or a + function that will do the same + * `modname`: module name must be a unique name to identify logs later. + """ + self.config = None + self.net = None + self.gears = {} + self.routern = 1 + self.switchn = 1 + self.modname = modname + self.errorsd = {} + self.errors = "" + self.peern = 1 + self.cfg_gen = 0 + self.exabgp_cmd = None + self._init_topo(topodef) + + logger.info("loading topology: {}".format(self.modname)) + + # @staticmethod + # def _mininet_reset(): + # "Reset the mininet environment" + # # Clean up the mininet environment + # os.system("mn -c > /dev/null 2>&1") + + def __str__(self): + return "Topogen()" + + def _init_topo(self, topodef): + """ + Initialize the topogily provided by the user. The user topology class + must call get_topogen() during build() to get the topogen object. + """ + # Set the global variable so the test cases can access it anywhere + set_topogen(self) + + # Increase host based limits + topotest.fix_host_limits() + + # Test for MPLS Kernel modules available + self.hasmpls = False + if not topotest.module_present("mpls-router"): + logger.info("MPLS tests will not run (missing mpls-router kernel module)") + elif not topotest.module_present("mpls-iptunnel"): + logger.info("MPLS tests will not run (missing mpls-iptunnel kernel module)") + else: + self.hasmpls = True + + # Load the default topology configurations + self._load_config() + + # Create new log directory + self.logdir = topotest.get_logs_path(g_extra_config["rundir"]) + subprocess.check_call( + "mkdir -p {0} && chmod 1777 {0}".format(self.logdir), shell=True + ) + try: + routertype = self.config.get(self.CONFIG_SECTION, "routertype") + # Only allow group, if it exist. + gid = grp.getgrnam(routertype)[2] + os.chown(self.logdir, 0, gid) + os.chmod(self.logdir, 0o775) + except KeyError: + # Allow anyone, but set the sticky bit to avoid file deletions + os.chmod(self.logdir, 0o1777) + + # Remove old twisty way of creating sub-classed topology object which has it's + # build method invoked which calls Topogen methods which then call Topo methods + # to create a topology within the Topo object, which is then used by + # Mininet(Micronet) to build the actual topology. + assert not inspect.isclass(topodef) + + self.net = Mininet(controller=None) + + # New direct way: Either a dictionary defines the topology or a build function + # is supplied, or a json filename all of which build the topology by calling + # Topogen methods which call Mininet(Micronet) methods to create the actual + # topology. + if not inspect.isclass(topodef): + if callable(topodef): + topodef(self) + self.net.configure_hosts() + elif is_string(topodef): + # topojson imports topogen in one function too, + # switch away from this use here to the topojson + # fixutre and remove this case + from lib.topojson import build_topo_from_json + + with open(topodef, "r") as topof: + self.json_topo = json.load(topof) + build_topo_from_json(self, self.json_topo) + self.net.configure_hosts() + elif topodef: + self.add_topology_from_dict(topodef) + + def add_topology_from_dict(self, topodef): + + keylist = ( + topodef.keys() + if isinstance(topodef, OrderedDict) + else sorted(topodef.keys()) + ) + # --------------------------- + # Create all referenced hosts + # --------------------------- + for oname in keylist: + tup = (topodef[oname],) if is_string(topodef[oname]) else topodef[oname] + for e in tup: + desc = e.split(":") + name = desc[0] + if name not in self.gears: + logging.debug("Adding router: %s", name) + self.add_router(name) + + # ------------------------------ + # Create all referenced switches + # ------------------------------ + for oname in keylist: + if oname is not None and oname not in self.gears: + logging.debug("Adding switch: %s", oname) + self.add_switch(oname) + + # ---------------- + # Create all links + # ---------------- + for oname in keylist: + if oname is None: + continue + tup = (topodef[oname],) if is_string(topodef[oname]) else topodef[oname] + for e in tup: + desc = e.split(":") + name = desc[0] + ifname = desc[1] if len(desc) > 1 else None + sifname = desc[2] if len(desc) > 2 else None + self.add_link(self.gears[oname], self.gears[name], sifname, ifname) + + self.net.configure_hosts() + + def _load_config(self): + """ + Loads the configuration file `pytest.ini` located at the root dir of + topotests. + """ + self.config = configparser.ConfigParser(tgen_defaults) + pytestini_path = os.path.join(CWD, "../pytest.ini") + self.config.read(pytestini_path) + + def add_router(self, name=None, cls=None, **params): + """ + Adds a new router to the topology. This function has the following + options: + * `name`: (optional) select the router name + * `daemondir`: (optional) custom daemon binary directory + * `routertype`: (optional) `frr` + Returns a TopoRouter. + """ + if cls is None: + cls = topotest.Router + if name is None: + name = "r{}".format(self.routern) + if name in self.gears: + raise KeyError("router already exists") + + params["frrdir"] = self.config.get(self.CONFIG_SECTION, "frrdir") + params["memleak_path"] = self.config.get(self.CONFIG_SECTION, "memleak_path") + if "routertype" not in params: + params["routertype"] = self.config.get(self.CONFIG_SECTION, "routertype") + + self.gears[name] = TopoRouter(self, cls, name, **params) + self.routern += 1 + return self.gears[name] + + def add_switch(self, name=None): + """ + Adds a new switch to the topology. This function has the following + options: + name: (optional) select the switch name + Returns the switch name and number. + """ + if name is None: + name = "s{}".format(self.switchn) + if name in self.gears: + raise KeyError("switch already exists") + + self.gears[name] = TopoSwitch(self, name) + self.switchn += 1 + return self.gears[name] + + def add_exabgp_peer(self, name, ip, defaultRoute): + """ + Adds a new ExaBGP peer to the topology. This function has the following + parameters: + * `ip`: the peer address (e.g. '1.2.3.4/24') + * `defaultRoute`: the peer default route (e.g. 'via 1.2.3.1') + """ + if name is None: + name = "peer{}".format(self.peern) + if name in self.gears: + raise KeyError("exabgp peer already exists") + + self.gears[name] = TopoExaBGP(self, name, ip=ip, defaultRoute=defaultRoute) + self.peern += 1 + return self.gears[name] + + def add_host(self, name, ip, defaultRoute): + """ + Adds a new host to the topology. This function has the following + parameters: + * `ip`: the peer address (e.g. '1.2.3.4/24') + * `defaultRoute`: the peer default route (e.g. 'via 1.2.3.1') + """ + if name is None: + name = "host{}".format(self.peern) + if name in self.gears: + raise KeyError("host already exists") + + self.gears[name] = TopoHost(self, name, ip=ip, defaultRoute=defaultRoute) + self.peern += 1 + return self.gears[name] + + def add_link(self, node1, node2, ifname1=None, ifname2=None): + """ + Creates a connection between node1 and node2. The nodes can be the + following: + * TopoGear + * TopoRouter + * TopoSwitch + """ + if not isinstance(node1, TopoGear): + raise ValueError("invalid node1 type") + if not isinstance(node2, TopoGear): + raise ValueError("invalid node2 type") + + if ifname1 is None: + ifname1 = node1.new_link() + if ifname2 is None: + ifname2 = node2.new_link() + + node1.register_link(ifname1, node2, ifname2) + node2.register_link(ifname2, node1, ifname1) + self.net.add_link(node1.name, node2.name, ifname1, ifname2) + + def get_gears(self, geartype): + """ + Returns a dictionary of all gears of type `geartype`. + + Normal usage: + * Dictionary iteration: + ```py + tgen = get_topogen() + router_dict = tgen.get_gears(TopoRouter) + for router_name, router in router_dict.items(): + # Do stuff + ``` + * List iteration: + ```py + tgen = get_topogen() + peer_list = tgen.get_gears(TopoExaBGP).values() + for peer in peer_list: + # Do stuff + ``` + """ + return dict( + (name, gear) + for name, gear in self.gears.items() + if isinstance(gear, geartype) + ) + + def routers(self): + """ + Returns the router dictionary (key is the router name and value is the + router object itself). + """ + return self.get_gears(TopoRouter) + + def exabgp_peers(self): + """ + Returns the exabgp peer dictionary (key is the peer name and value is + the peer object itself). + """ + return self.get_gears(TopoExaBGP) + + def start_topology(self): + """Starts the topology class.""" + logger.info("starting topology: {}".format(self.modname)) + self.net.start() + + def start_router(self, router=None): + """ + Call the router startRouter method. + If no router is specified it is called for all registered routers. + """ + if router is None: + # pylint: disable=r1704 + # XXX should be hosts? + for _, router in self.routers().items(): + router.start() + else: + if isinstance(router, str): + router = self.gears[router] + + router.start() + + def stop_topology(self): + """ + Stops the network topology. This function will call the stop() function + of all gears before calling the mininet stop function, so they can have + their oportunity to do a graceful shutdown. stop() is called twice. The + first is a simple kill with no sleep, the second will sleep if not + killed and try with a different signal. + """ + logger.info("stopping topology: {}".format(self.modname)) + errors = "" + for gear in self.gears.values(): + errors += gear.stop() + if len(errors) > 0: + logger.error( + "Errors found post shutdown - details follow: {}".format(errors) + ) + + self.net.stop() + + def get_exabgp_cmd(self): + if not self.exabgp_cmd: + self.exabgp_cmd = get_exabgp_cmd(self.net) + return self.exabgp_cmd + + def cli(self): + """ + Interrupt the test and call the command line interface for manual + inspection. Should be only used on non production code. + """ + self.net.cli() + + mininet_cli = cli + + def is_memleak_enabled(self): + "Returns `True` if memory leak report is enable, otherwise `False`." + # On router failure we can't run the memory leak test + if self.routers_have_failure(): + return False + + memleak_file = os.environ.get("TOPOTESTS_CHECK_MEMLEAK") or self.config.get( + self.CONFIG_SECTION, "memleak_path" + ) + if memleak_file == "" or memleak_file == None: + return False + return True + + def report_memory_leaks(self, testname=None): + "Run memory leak test and reports." + if not self.is_memleak_enabled(): + return + + # If no name was specified, use the test module name + if testname is None: + testname = self.modname + + router_list = self.routers().values() + for router in router_list: + router.report_memory_leaks(self.modname) + + def set_error(self, message, code=None): + "Sets an error message and signal other tests to skip." + logger.info(message) + + # If no code is defined use a sequential number + if code is None: + code = len(self.errorsd) + + self.errorsd[code] = message + self.errors += "\n{}: {}".format(code, message) + + def has_errors(self): + "Returns whether errors exist or not." + return len(self.errorsd) > 0 + + def routers_have_failure(self): + "Runs an assertion to make sure that all routers are running." + if self.has_errors(): + return True + + errors = "" + router_list = self.routers().values() + for router in router_list: + result = router.check_router_running() + if result != "": + errors += result + "\n" + + if errors != "": + self.set_error(errors, "router_error") + assert False, errors + return True + return False + + +# +# Topology gears (equipment) +# + + +class TopoGear(object): + "Abstract class for type checking" + + def __init__(self, tgen, name, **params): + self.tgen = tgen + self.name = name + self.params = params + self.links = {} + self.linkn = 0 + + # Would be nice for this to point at the gears log directory rather than the + # test's. + self.logdir = tgen.logdir + self.gearlogdir = None + + def __str__(self): + links = "" + for myif, dest in self.links.items(): + _, destif = dest + if links != "": + links += "," + links += '"{}"<->"{}"'.format(myif, destif) + + return 'TopoGear<name="{}",links=[{}]>'.format(self.name, links) + + @property + def net(self): + return self.tgen.net[self.name] + + def start(self): + "Basic start function that just reports equipment start" + logger.info('starting "{}"'.format(self.name)) + + def stop(self, wait=True, assertOnError=True): + "Basic stop function that just reports equipment stop" + logger.info('"{}" base stop called'.format(self.name)) + return "" + + def cmd(self, command, **kwargs): + """ + Runs the provided command string in the router and returns a string + with the response. + """ + return self.net.cmd_legacy(command, **kwargs) + + def cmd_raises(self, command, **kwargs): + """ + Runs the provided command string in the router and returns a string + with the response. Raise an exception on any error. + """ + return self.net.cmd_raises(command, **kwargs) + + run = cmd + + def popen(self, *params, **kwargs): + """ + Creates a pipe with the given command. Same args as python Popen. + If `command` is a string then will be invoked with shell, otherwise + `command` is a list and will be invoked w/o shell. Returns a popen object. + """ + return self.net.popen(*params, **kwargs) + + def add_link(self, node, myif=None, nodeif=None): + """ + Creates a link (connection) between myself and the specified node. + Interfaces name can be speficied with: + myif: the interface name that will be created in this node + nodeif: the target interface name that will be created on the remote node. + """ + self.tgen.add_link(self, node, myif, nodeif) + + def link_enable(self, myif, enabled=True, netns=None): + """ + Set this node interface administrative state. + myif: this node interface name + enabled: whether we should enable or disable the interface + """ + if myif not in self.links.keys(): + raise KeyError("interface doesn't exists") + + if enabled is True: + operation = "up" + else: + operation = "down" + + logger.info( + 'setting node "{}" link "{}" to state "{}"'.format( + self.name, myif, operation + ) + ) + extract = "" + if netns is not None: + extract = "ip netns exec {} ".format(netns) + + return self.run("{}ip link set dev {} {}".format(extract, myif, operation)) + + def peer_link_enable(self, myif, enabled=True, netns=None): + """ + Set the peer interface administrative state. + myif: this node interface name + enabled: whether we should enable or disable the interface + + NOTE: this is used to simulate a link down on this node, since when the + peer disables their interface our interface status changes to no link. + """ + if myif not in self.links.keys(): + raise KeyError("interface doesn't exists") + + node, nodeif = self.links[myif] + node.link_enable(nodeif, enabled, netns) + + def new_link(self): + """ + Generates a new unique link name. + + NOTE: This function should only be called by Topogen. + """ + ifname = "{}-eth{}".format(self.name, self.linkn) + self.linkn += 1 + return ifname + + def register_link(self, myif, node, nodeif): + """ + Register link between this node interface and outside node. + + NOTE: This function should only be called by Topogen. + """ + if myif in self.links.keys(): + raise KeyError("interface already exists") + + self.links[myif] = (node, nodeif) + + def _setup_tmpdir(self): + topotest.setup_node_tmpdir(self.logdir, self.name) + self.gearlogdir = "{}/{}".format(self.logdir, self.name) + return "{}/{}.log".format(self.logdir, self.name) + + +class TopoRouter(TopoGear): + """ + Router abstraction. + """ + + # The default required directories by FRR + PRIVATE_DIRS = [ + "/etc/frr", + "/etc/snmp", + "/var/run/frr", + "/var/log", + ] + + # Router Daemon enumeration definition. + RD_FRR = 0 # not a daemon, but use to setup unified configs + RD_ZEBRA = 1 + RD_RIP = 2 + RD_RIPNG = 3 + RD_OSPF = 4 + RD_OSPF6 = 5 + RD_ISIS = 6 + RD_BGP = 7 + RD_LDP = 8 + RD_PIM = 9 + RD_EIGRP = 10 + RD_NHRP = 11 + RD_STATIC = 12 + RD_BFD = 13 + RD_SHARP = 14 + RD_BABEL = 15 + RD_PBRD = 16 + RD_PATH = 17 + RD_SNMP = 18 + RD_PIM6 = 19 + RD = { + RD_FRR: "frr", + RD_ZEBRA: "zebra", + RD_RIP: "ripd", + RD_RIPNG: "ripngd", + RD_OSPF: "ospfd", + RD_OSPF6: "ospf6d", + RD_ISIS: "isisd", + RD_BGP: "bgpd", + RD_PIM: "pimd", + RD_PIM6: "pim6d", + RD_LDP: "ldpd", + RD_EIGRP: "eigrpd", + RD_NHRP: "nhrpd", + RD_STATIC: "staticd", + RD_BFD: "bfdd", + RD_SHARP: "sharpd", + RD_BABEL: "babeld", + RD_PBRD: "pbrd", + RD_PATH: "pathd", + RD_SNMP: "snmpd", + } + + def __init__(self, tgen, cls, name, **params): + """ + The constructor has the following parameters: + * tgen: Topogen object + * cls: router class that will be used to instantiate + * name: router name + * daemondir: daemon binary directory + * routertype: 'frr' + """ + super(TopoRouter, self).__init__(tgen, name, **params) + self.routertype = params.get("routertype", "frr") + if "privateDirs" not in params: + params["privateDirs"] = self.PRIVATE_DIRS + + # Propagate the router log directory + logfile = self._setup_tmpdir() + params["logdir"] = self.logdir + + self.logger = topolog.get_logger(name, log_level="debug", target=logfile) + params["logger"] = self.logger + tgen.net.add_host(self.name, cls=cls, **params) + topotest.fix_netns_limits(tgen.net[name]) + + # Mount gear log directory on a common path + self.net.bind_mount(self.gearlogdir, "/tmp/gearlogdir") + + # Ensure pid file + with open(os.path.join(self.logdir, self.name + ".pid"), "w") as f: + f.write(str(self.net.pid) + "\n") + + def __str__(self): + gear = super(TopoRouter, self).__str__() + gear += " TopoRouter<>" + return gear + + def check_capability(self, daemon, param): + """ + Checks a capability daemon against an argument option + Return True if capability available. False otherwise + """ + daemonstr = self.RD.get(daemon) + self.logger.info('check capability {} for "{}"'.format(param, daemonstr)) + return self.net.checkCapability(daemonstr, param) + + def load_frr_config(self, source, daemons=None): + """ + Loads the unified configuration file source + Start the daemons in the list + If daemons is None, try to infer daemons from the config file + """ + self.load_config(self.RD_FRR, source) + if not daemons: + # Always add zebra + self.load_config(self.RD_ZEBRA) + for daemon in self.RD: + # This will not work for all daemons + daemonstr = self.RD.get(daemon).rstrip("d") + if daemonstr == "pim": + grep_cmd = "grep 'ip {}' {}".format(daemonstr, source) + else: + grep_cmd = "grep 'router {}' {}".format(daemonstr, source) + result = self.run(grep_cmd).strip() + if result: + self.load_config(daemon) + else: + for daemon in daemons: + self.load_config(daemon) + + def load_config(self, daemon, source=None, param=None): + """Loads daemon configuration from the specified source + Possible daemon values are: TopoRouter.RD_ZEBRA, TopoRouter.RD_RIP, + TopoRouter.RD_RIPNG, TopoRouter.RD_OSPF, TopoRouter.RD_OSPF6, + TopoRouter.RD_ISIS, TopoRouter.RD_BGP, TopoRouter.RD_LDP, + TopoRouter.RD_PIM, TopoRouter.RD_PIM6, TopoRouter.RD_PBR, + TopoRouter.RD_SNMP. + + 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. + + This API unfortunately allows for source to not exist for any and + all routers. + """ + daemonstr = self.RD.get(daemon) + self.logger.info('loading "{}" configuration: {}'.format(daemonstr, source)) + self.net.loadConf(daemonstr, source, param) + + def check_router_running(self): + """ + Run a series of checks and returns a status string. + """ + self.logger.info("checking if daemons are running") + return self.net.checkRouterRunning() + + def start(self): + """ + Start router: + * Load modules + * Clean up files + * Configure interfaces + * Start daemons (e.g. FRR) + * Configure daemon logging files + """ + + nrouter = self.net + result = nrouter.startRouter(self.tgen) + + # Enable command logging + + # Enable all daemon command logging, logging files + # and set them to the start dir. + for daemon, enabled in nrouter.daemons.items(): + if enabled and daemon != "snmpd": + self.vtysh_cmd( + "\n".join( + [ + "clear log cmdline-targets", + "conf t", + "log file {}.log debug".format(daemon), + "log commands", + "log timestamp precision 3", + ] + ), + daemon=daemon, + ) + + if result != "": + self.tgen.set_error(result) + elif nrouter.daemons["ldpd"] == 1 or nrouter.daemons["pathd"] == 1: + # Enable MPLS processing on all interfaces. + for interface in self.links: + topotest.sysctl_assure( + nrouter, "net.mpls.conf.{}.input".format(interface), 1 + ) + + return result + + def stop(self): + """ + Stop router cleanly: + * Signal daemons twice, once with SIGTERM, then with SIGKILL. + """ + self.logger.debug("stopping (no assert)") + return self.net.stopRouter(False) + + def startDaemons(self, daemons): + """ + Start Daemons: to start specific daemon(user defined daemon only) + * Start daemons (e.g. FRR) + * Configure daemon logging files + """ + self.logger.debug("starting") + nrouter = self.net + result = nrouter.startRouterDaemons(daemons) + + if daemons is None: + daemons = nrouter.daemons.keys() + + # Enable all daemon command logging, logging files + # and set them to the start dir. + for daemon in daemons: + enabled = nrouter.daemons[daemon] + if enabled and daemon != "snmpd": + self.vtysh_cmd( + "\n".join( + [ + "clear log cmdline-targets", + "conf t", + "log file {}.log debug".format(daemon), + "log commands", + "log timestamp precision 3", + ] + ), + daemon=daemon, + ) + + if result != "": + self.tgen.set_error(result) + + return result + + def killDaemons(self, daemons, wait=True, assertOnError=True): + """ + Kill specific daemon(user defined daemon only) + forcefully using SIGKILL + """ + self.logger.debug("Killing daemons using SIGKILL..") + return self.net.killRouterDaemons(daemons, wait, assertOnError) + + def vtysh_cmd(self, command, isjson=False, daemon=None): + """ + Runs the provided command string in the vty shell and returns a string + with the response. + + This function also accepts multiple commands, but this mode does not + return output for each command. See vtysh_multicmd() for more details. + """ + # Detect multi line commands + if command.find("\n") != -1: + return self.vtysh_multicmd(command, daemon=daemon) + + dparam = "" + if daemon is not None: + dparam += "-d {}".format(daemon) + + vtysh_command = 'vtysh {} -c "{}" 2>/dev/null'.format(dparam, command) + + self.logger.info('vtysh command => "{}"'.format(command)) + output = self.run(vtysh_command) + + dbgout = output.strip() + if dbgout: + if "\n" in dbgout: + dbgout = dbgout.replace("\n", "\n\t") + self.logger.info("vtysh result:\n\t{}".format(dbgout)) + else: + self.logger.info('vtysh result: "{}"'.format(dbgout)) + + if isjson is False: + return output + + try: + return json.loads(output) + except ValueError as error: + logger.warning( + "vtysh_cmd: %s: failed to convert json output: %s: %s", + self.name, + str(output), + str(error), + ) + return {} + + def vtysh_multicmd(self, commands, pretty_output=True, daemon=None): + """ + Runs the provided commands in the vty shell and return the result of + execution. + + pretty_output: defines how the return value will be presented. When + True it will show the command as they were executed in the vty shell, + otherwise it will only show lines that failed. + """ + # Prepare the temporary file that will hold the commands + fname = topotest.get_file(commands) + + dparam = "" + if daemon is not None: + dparam += "-d {}".format(daemon) + + # Run the commands and delete the temporary file + if pretty_output: + vtysh_command = "vtysh {} < {}".format(dparam, fname) + else: + vtysh_command = "vtysh {} -f {}".format(dparam, fname) + + dbgcmds = commands if is_string(commands) else "\n".join(commands) + dbgcmds = "\t" + dbgcmds.replace("\n", "\n\t") + self.logger.info("vtysh command => FILE:\n{}".format(dbgcmds)) + + res = self.run(vtysh_command) + os.unlink(fname) + + dbgres = res.strip() + if dbgres: + if "\n" in dbgres: + dbgres = dbgres.replace("\n", "\n\t") + self.logger.info("vtysh result:\n\t{}".format(dbgres)) + else: + self.logger.info('vtysh result: "{}"'.format(dbgres)) + return res + + def report_memory_leaks(self, testname): + """ + Runs the router memory leak check test. Has the following parameter: + testname: the test file name for identification + + NOTE: to run this you must have the environment variable + TOPOTESTS_CHECK_MEMLEAK set or memleak_path configured in `pytest.ini`. + """ + memleak_file = ( + os.environ.get("TOPOTESTS_CHECK_MEMLEAK") or self.params["memleak_path"] + ) + if memleak_file == "" or memleak_file == None: + return + + self.stop() + + self.logger.info("running memory leak report") + self.net.report_memory_leaks(memleak_file, testname) + + def version_info(self): + "Get equipment information from 'show version'." + output = self.vtysh_cmd("show version").split("\n")[0] + columns = topotest.normalize_text(output).split(" ") + try: + return { + "type": columns[0], + "version": columns[1], + } + except IndexError: + return { + "type": None, + "version": None, + } + + def has_version(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.has_version('>', '1.0') + """ + return self.net.checkRouterVersion(cmpop, version) + + def has_type(self, rtype): + """ + Compares router type with `rtype`. Returns `True` if the type matches, + otherwise `false`. + """ + curtype = self.version_info()["type"] + return rtype == curtype + + def has_mpls(self): + return self.net.hasmpls + + +class TopoSwitch(TopoGear): + """ + Switch abstraction. Has the following properties: + * cls: switch class that will be used to instantiate + * name: switch name + """ + + # pylint: disable=too-few-public-methods + + def __init__(self, tgen, name, **params): + super(TopoSwitch, self).__init__(tgen, name, **params) + tgen.net.add_switch(name) + + def __str__(self): + gear = super(TopoSwitch, self).__str__() + gear += " TopoSwitch<>" + return gear + + +class TopoHost(TopoGear): + "Host abstraction." + # pylint: disable=too-few-public-methods + + def __init__(self, tgen, name, **params): + """ + Mininet has the following known `params` for hosts: + * `ip`: the IP address (string) for the host interface + * `defaultRoute`: the default route that will be installed + (e.g. 'via 10.0.0.1') + * `privateDirs`: directories that will be mounted on a different domain + (e.g. '/etc/important_dir'). + """ + super(TopoHost, self).__init__(tgen, name, **params) + + # Propagate the router log directory + logfile = self._setup_tmpdir() + params["logdir"] = self.logdir + + # Odd to have 2 logfiles for each host + self.logger = topolog.get_logger(name, log_level="debug", target=logfile) + params["logger"] = self.logger + tgen.net.add_host(name, **params) + topotest.fix_netns_limits(tgen.net[name]) + + # Mount gear log directory on a common path + self.net.bind_mount(self.gearlogdir, "/tmp/gearlogdir") + + def __str__(self): + gear = super(TopoHost, self).__str__() + gear += ' TopoHost<ip="{}",defaultRoute="{}",privateDirs="{}">'.format( + self.params["ip"], + self.params["defaultRoute"], + str(self.params["privateDirs"]), + ) + return gear + + +class TopoExaBGP(TopoHost): + "ExaBGP peer abstraction." + # pylint: disable=too-few-public-methods + + PRIVATE_DIRS = [ + "/etc/exabgp", + "/var/run/exabgp", + "/var/log", + ] + + def __init__(self, tgen, name, **params): + """ + ExaBGP usually uses the following parameters: + * `ip`: the IP address (string) for the host interface + * `defaultRoute`: the default route that will be installed + (e.g. 'via 10.0.0.1') + + Note: the different between a host and a ExaBGP peer is that this class + has a privateDirs already defined and contains functions to handle ExaBGP + things. + """ + params["privateDirs"] = self.PRIVATE_DIRS + super(TopoExaBGP, self).__init__(tgen, name, **params) + + def __str__(self): + gear = super(TopoExaBGP, self).__str__() + gear += " TopoExaBGP<>".format() + return gear + + def start(self, peer_dir, env_file=None): + """ + Start running ExaBGP daemon: + * Copy all peer* folder contents into /etc/exabgp + * Copy exabgp env file if specified + * Make all python files runnable + * Run ExaBGP with env file `env_file` and configuration peer*/exabgp.cfg + """ + exacmd = self.tgen.get_exabgp_cmd() + assert exacmd, "Can't find a usabel ExaBGP (must be < version 4)" + + self.run("mkdir -p /etc/exabgp") + self.run("chmod 755 /etc/exabgp") + self.run("cp {}/exa-* /etc/exabgp/".format(CWD)) + self.run("cp {}/* /etc/exabgp/".format(peer_dir)) + if env_file is not None: + self.run("cp {} /etc/exabgp/exabgp.env".format(env_file)) + self.run("chmod 644 /etc/exabgp/*") + self.run("chmod a+x /etc/exabgp/*.py") + self.run("chown -R exabgp:exabgp /etc/exabgp") + + output = self.run(exacmd + " -e /etc/exabgp/exabgp.env /etc/exabgp/exabgp.cfg") + if output == None or len(output) == 0: + output = "<none>" + + logger.info("{} exabgp started, output={}".format(self.name, output)) + + def stop(self, wait=True, assertOnError=True): + "Stop ExaBGP peer and kill the daemon" + self.run("kill `cat /var/run/exabgp/exabgp.pid`") + return "" + + +# +# Diagnostic function +# + +# Disable linter branch warning. It is expected to have these here. +# pylint: disable=R0912 +def diagnose_env_linux(rundir): + """ + Run diagnostics in the running environment. Returns `True` when everything + is ok, otherwise `False`. + """ + ret = True + + # Load configuration + config = configparser.ConfigParser(defaults=tgen_defaults) + pytestini_path = os.path.join(CWD, "../pytest.ini") + config.read(pytestini_path) + + # Test log path exists before installing handler. + os.system("mkdir -p " + rundir) + # Log diagnostics to file so it can be examined later. + fhandler = logging.FileHandler(filename="{}/diagnostics.txt".format(rundir)) + fhandler.setLevel(logging.DEBUG) + fhandler.setFormatter(logging.Formatter(fmt=topolog.FORMAT)) + logger.addHandler(fhandler) + + logger.info("Running environment diagnostics") + + # Assert that we are running as root + if os.getuid() != 0: + logger.error("you must run topotest as root") + ret = False + + # Assert that we have mininet + # if os.system("which mn >/dev/null 2>/dev/null") != 0: + # logger.error("could not find mininet binary (mininet is not installed)") + # ret = False + + # Assert that we have iproute installed + if os.system("which ip >/dev/null 2>/dev/null") != 0: + logger.error("could not find ip binary (iproute is not installed)") + ret = False + + # Assert that we have gdb installed + if os.system("which gdb >/dev/null 2>/dev/null") != 0: + logger.error("could not find gdb binary (gdb is not installed)") + ret = False + + # Assert that FRR utilities exist + frrdir = config.get("topogen", "frrdir") + if not os.path.isdir(frrdir): + logger.error("could not find {} directory".format(frrdir)) + ret = False + else: + try: + pwd.getpwnam("frr")[2] + except KeyError: + logger.warning('could not find "frr" user') + + try: + grp.getgrnam("frr")[2] + except KeyError: + logger.warning('could not find "frr" group') + + try: + if "frr" not in grp.getgrnam("frrvty").gr_mem: + logger.error( + '"frr" user and group exist, but user is not under "frrvty"' + ) + except KeyError: + logger.warning('could not find "frrvty" group') + + for fname in [ + "zebra", + "ospfd", + "ospf6d", + "bgpd", + "ripd", + "ripngd", + "isisd", + "pimd", + "pim6d", + "ldpd", + "pbrd", + ]: + path = os.path.join(frrdir, fname) + if not os.path.isfile(path): + # LDPd is an exception + if fname == "ldpd": + logger.info( + "could not find {} in {}".format(fname, frrdir) + + "(LDPd tests will not run)" + ) + continue + + logger.error("could not find {} in {}".format(fname, frrdir)) + ret = False + else: + if fname != "zebra": + continue + + os.system("{} -v 2>&1 >{}/frr_zebra.txt".format(path, rundir)) + + # Test MPLS availability + krel = platform.release() + if topotest.version_cmp(krel, "4.5") < 0: + logger.info( + 'LDPd tests will not run (have kernel "{}", but it requires 4.5)'.format( + krel + ) + ) + + # Test for MPLS Kernel modules available + if not topotest.module_present("mpls-router", load=False) != 0: + logger.info("LDPd tests will not run (missing mpls-router kernel module)") + if not topotest.module_present("mpls-iptunnel", load=False) != 0: + logger.info("LDPd tests will not run (missing mpls-iptunnel kernel module)") + + if not get_exabgp_cmd(): + logger.warning("Failed to find exabgp < 4") + + logger.removeHandler(fhandler) + fhandler.close() + + return ret + + +def diagnose_env_freebsd(): + return True + + +def diagnose_env(rundir): + if sys.platform.startswith("linux"): + return diagnose_env_linux(rundir) + elif sys.platform.startswith("freebsd"): + return diagnose_env_freebsd() + + return False |