From 0fc720d48a8efba203c3a030aaf393137c47b258 Mon Sep 17 00:00:00 2001 From: Tobias Waldekranz Date: Wed, 24 May 2023 00:23:30 +0200 Subject: [PATCH] test/infamy: Initial add of python based test library --- test/infamy/.gitignore | 1 + test/infamy/__init__.py | 14 ++++ test/infamy/env.py | 93 ++++++++++++++++++++++++++ test/infamy/neigh.py | 18 +++++ test/infamy/netconf.py | 116 +++++++++++++++++++++++++++++++++ test/infamy/netns.py | 68 +++++++++++++++++++ test/infamy/tap.py | 111 +++++++++++++++++++++++++++++++ test/infamy/topologies/1x1.dot | 23 +++++++ test/infamy/topologies/1x2.dot | 24 +++++++ test/infamy/topology.py | 68 +++++++++++++++++++ 10 files changed, 536 insertions(+) create mode 100644 test/infamy/.gitignore create mode 100644 test/infamy/__init__.py create mode 100644 test/infamy/env.py create mode 100644 test/infamy/neigh.py create mode 100644 test/infamy/netconf.py create mode 100644 test/infamy/netns.py create mode 100644 test/infamy/tap.py create mode 100644 test/infamy/topologies/1x1.dot create mode 100644 test/infamy/topologies/1x2.dot create mode 100644 test/infamy/topology.py diff --git a/test/infamy/.gitignore b/test/infamy/.gitignore new file mode 100644 index 00000000..225fc6f6 --- /dev/null +++ b/test/infamy/.gitignore @@ -0,0 +1 @@ +/__pycache__ diff --git a/test/infamy/__init__.py b/test/infamy/__init__.py new file mode 100644 index 00000000..464cce09 --- /dev/null +++ b/test/infamy/__init__.py @@ -0,0 +1,14 @@ +import os + +from .env import Env +from .netns import IsolatedMacVlan +from .tap import Test + +def std_topology(name): + return os.path.realpath( + os.path.join( + os.path.dirname(__file__), + "topologies", + name + ".dot" + ) + ) diff --git a/test/infamy/env.py b/test/infamy/env.py new file mode 100644 index 00000000..d3018a9e --- /dev/null +++ b/test/infamy/env.py @@ -0,0 +1,93 @@ +import argparse +import networkx +import os +import pydot +import shlex +import sys + +from . import neigh, netconf, tap, topology + +class ArgumentParser(argparse.ArgumentParser): + def __init__(self, ltop): + super().__init__() + + self.add_argument("-d", "--debug", default=False, action="store_true") + self.add_argument("-l", "--logical-topology", dest="ltop", default=ltop) + self.add_argument("-y", "--yangdir", default=None) + self.add_argument("ptop", nargs=1, metavar="topology") + + +class Env(object): + def __init__(self, ltop=None, argv=sys.argv[1::], environ=os.environ): + if "INFAMY_ARGS" in environ: + argv = shlex.split(environ["INFAMY_ARGS"]) + argv + + self.args = ArgumentParser(ltop).parse_args(argv) + + pdot = pydot.graph_from_dot_file(self.args.ptop[0])[0] + self.ptop = networkx.nx_pydot.read_dot(self.args.ptop[0]) + + if self.args.ltop: + self.ltop = networkx.nx_pydot.read_dot(self.args.ltop) + ldot = pydot.graph_from_dot_file(self.args.ltop)[0] + mapping = topology.find_mapping(pdot, ldot) + if not mapping: + raise tap.TestSkip() + + self.mapping = {} + for (log, phy) in mapping.items(): + if ":" in log: + lnode, lport = log.split(":") + pnode, pport = phy.split(":") + + self._map_node(lnode, pnode) + self._map_port((lnode, lport), (pnode, pport)) + else: + self._map_node(log, phy) + + def _map_node(self, lnode, pnode): + if lnode in self.mapping: + assert(self.mapping[lnode][None] == pnode) + else: + self.mapping[lnode] = { None: pnode } + + def _map_port(self, log, phy): + (lnode, lport) = log + (pnode, pport) = phy + + if lport in self.mapping[lnode]: + assert(self.mapping[lnode][lport] == pport) + else: + self.mapping[lnode][lport] = pport + + def xlate(self, lnode, lport=None): + if lnode not in self.mapping: + return None + + nodemap = self.mapping[lnode] + + if lport not in nodemap: + return None + + if not lport: + return nodemap[None] + + return (nodemap[None], nodemap[lport]) + + + def attach(self, node, port): + if self.mapping: + mapping = self.mapping[node] + node, port = mapping[None], mapping[port] + else: + mapping = None + + hostport = list(self.ptop.neighbors(f"{node}:{port}"))[0] + hnode, hport = hostport.split(":") + + mgmtip = neigh.ll6ping(hport) + return netconf.Device( + location=netconf.Location(mgmtip), + mapping=mapping, + yangdir=self.args.yangdir + ) diff --git a/test/infamy/neigh.py b/test/infamy/neigh.py new file mode 100644 index 00000000..4ee32006 --- /dev/null +++ b/test/infamy/neigh.py @@ -0,0 +1,18 @@ +import json +import re +import subprocess + +def ll6ping(ifname, flags=["-w60", "-c1", "-L", "-n"]): + argv = ["ping"] + flags + ["ff02::1%{}".format(ifname)] + + try: + ping = subprocess.run(argv, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, check=True) + except subprocess.CalledProcessError: + return None + + m = re.search("^\d+ bytes from ([:0-9a-f]+%\S+):", ping.stdout, re.MULTILINE) + return m.group(1) if m else None + diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py new file mode 100644 index 00000000..09579b00 --- /dev/null +++ b/test/infamy/netconf.py @@ -0,0 +1,116 @@ + +from collections import namedtuple +from dataclasses import dataclass + +import socket + +import libyang +import lxml +import netconf_client.connect +import netconf_client.ncclient + +modinfo_fields = ("identifier", "version", "format", "namespace") +ModInfoTuple = namedtuple("ModInfoTuple", modinfo_fields) +class ModInfo(ModInfoTuple): + def xmlns(self): + return f"xmlns:{self.identifier}=\"{self.namespace}\"" + +NS = { + "ietf-netconf-monitoring": "urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring", +} + +@dataclass +class Location: + host: str + port: int = 830 + username: str = "admin" + password: str = "admin" + +class Device(object): + def __init__(self, + location: Location, + mapping: dict, + yangdir: None | str = None): + + self.mapping = mapping + self.ly = libyang.Context(yangdir) + + self._ncc_init(location) + self._ly_init(yangdir) + # self.update_schema() + + def _ncc_init(self, location): + ai = socket.getaddrinfo(location.host, location.port, + 0, 0, socket.SOL_TCP) + sock = socket.socket(ai[0][0], ai[0][1], 0) + sock.settimeout(60) + sock.connect(ai[0][4]) + sock.settimeout(None) + + session = netconf_client.connect.connect_ssh(sock=sock, + username=location.username, + password=location.password) + self.ncc = netconf_client.ncclient.Manager(session) + + def _ly_init(self, yangdir): + self.ly = libyang.Context(yangdir) + + lib = self.ly.load_module("ietf-yang-library") + ns = libyang.util.c2str(lib.cdata.ns) + + xml = lxml.etree.tostring(self.ncc.get(filter=f""" + + + """).data_ele[0]) + + data = self.ly.parse_data("xml", libyang.IOType.MEMORY, + xml, parse_only=True).print_dict() + + self.modules = { m["name"] : m for m in data["modules-state"]["module"] } + + for ms in self.modules.values(): + if ms["conformance-type"] != "implement": + continue + + mod = self.ly.load_module(ms["name"]) + + # TODO: ms["feature"] contains the list of enabled + # features, so ideally we should only enable the supported + # ones. However, features can depend on each other, so the + # naïve looping approach doesn't work. + mod.feature_enable_all() + + def _modules_in_xpath(self, xpath): + modnames = [] + + # Find all referenced models + for seg in xpath.split("/"): + if ":" in seg: + modname, node = seg.split(":") + modnames.append(modname) + + return list(filter(lambda m: m["name"] in modnames, + self.modules.values())) + + def get_config(self, xpath): + # Figure out which modules we are referencing + mods = self._modules_in_xpath(xpath) + + # Fetch the data + xmlns = " ".join([f"xmlns:{m['name']}=\"{m['namespace']}\"" for m in mods]) + filt = f"" + cfg = lxml.etree.tostring(self.ncc.get_config(filter=filt).data_ele[0]) + + return self.ly.parse_data_mem(cfg, "xml", parse_only=True) + + def get_config_dict(self, xpath): + return self.get_config(xpath).print_dict() + + def put_config(self, edit): + xml = "" + edit + "" + self.ncc.edit_config(xml) + + def put_config_dict(self, modname, edit): + mod = self.ly.get_module(modname) + lyd = mod.parse_data_dict(edit, no_state=True) + return self.put_config(lyd.print_mem("xml", with_siblings=True, pretty=False)) diff --git a/test/infamy/netns.py b/test/infamy/netns.py new file mode 100644 index 00000000..d62c79f9 --- /dev/null +++ b/test/infamy/netns.py @@ -0,0 +1,68 @@ +import subprocess + +class IsolatedMacVlan: + def __init__(self, parent, ifname="iface", lo=True): + self.parent, self.ifname, self.lo = parent, ifname, lo + + def __enter__(self): + self.sleeper = subprocess.Popen(["unshare", "-r", "-n", "sh", "-c", + "echo && exec sleep infinity"], + stdout=subprocess.PIPE) + self.sleeper.stdout.readline() + + try: + subprocess.run(["ip", "link", "add", + "dev", self.ifname, + "link", self.parent, + "netns", str(self.sleeper.pid), + "type", "macvlan"], check=True) + self.runsh(f""" + while ! ip link show dev {self.ifname}; do + sleep 0.1 + done + """) + except Exception as e: + self.__exit__(self, None, None, None) + raise e + + if self.lo: + try: + self.run(["ip", "link", "set", "dev", "lo", "up"]) + except Exception as e: + self.__exit__(self, None, None, None) + raise e + + return self + + def __exit__(self, val, typ, tb): + self.sleeper.kill() + self.sleeper.wait() + + def _mangle_subprocess_args(self, args, kwargs): + if args: + args = list(args) + if type(args[0]) == str: + if "shell" in kwargs and kwargs["shell"]: + args[0] = ["/bin/sh", "-c", args[0]] + kwargs["shell"] = False + else: + args[0] = [args[0]] + + if type(args[0]) == list: + args[0] = ["nsenter", "-t", str(self.sleeper.pid), + "-n", "-U", "--preserve-credentials"] + args[0] + return args, kwargs + + raise ValueError("Unable mangle subprocess arguments") + + def run(self, *args, **kwargs): + args, kwargs = self._mangle_subprocess_args(args, kwargs) + return subprocess.run(*args, **kwargs) + + def popen(self, *args, **kwargs): + args, kwargs = self._mangle_subprocess_args(args, kwargs) + return subprocess.Popen(*args, **kwargs) + + def runsh(self, script): + return self.run("/bin/sh", text=True, input=script, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) diff --git a/test/infamy/tap.py b/test/infamy/tap.py new file mode 100644 index 00000000..28a6506c --- /dev/null +++ b/test/infamy/tap.py @@ -0,0 +1,111 @@ +import contextlib +import datetime +import sys +import traceback + +class Test: + def __init__(self, output=sys.stdout): + self.out = output + self.commenter = CommentWriter(self.out) + if self.out == sys.stdout: + sys.stdout = self.commenter + + self.steps = 0 + + def __enter__(self): + now = datetime.datetime.now().strftime("%F %T") + self.out.write(f"# Starting ({now})\n") + return self + + def __exit__(self, _, e, __): + now = datetime.datetime.now().strftime("%F %T") + self.out.write(f"# Exiting ({now})\n") + + if not e: + self._not_ok("Missing explicit test result\n") + else: + if type(e) in (TestPass, TestSkip): + self.out.write(f"{self.steps}..{self.steps}\n") + raise SystemExit(0) + + traceback.print_exception(e, file=self.commenter) + + raise SystemExit(1) + + @contextlib.contextmanager + def step(self, msg): + try: + yield + self._ok(msg) + + except Exception as e: + if type(e) == TestPass: + self._ok(msg) + elif type(e) == TestSkip: + self._ok(directive="skip") + elif type(e) == TestFail: + self._not_ok(msg) + else: + self._not_ok(msg) + + raise e + + def _report(self, tag, msg): + self.steps += 1 + self.out.write(f"{tag} {self.steps}{msg}\n") + + def _ok(self, msg="", directive=None): + if msg: + msg = " - " + msg + + if directive: + msg = msg + " # " + directive + + self._report("ok", msg) + + def _not_ok(self, msg): + self._report("not ok", " - " + msg) + + def succeed(self): + raise TestPass() + + def skip(self): + raise TestSkip() + + def fail(self): + raise TestFail() + +class TestResult(Exception): + pass + +class TestPass(TestResult): + pass +class TestFail(TestResult): + pass +class TestSkip(TestResult): + pass + +class CommentWriter: + def __init__(self, f): + self.f = f + self.at_nl = True + + def write(self, data): + if self.at_nl: + data = "# " + data + self.at_nl = False + if not len(data): + return + + if data.endswith("\n"): + self.at_nl = True + data = data[:-1] + + data = data.replace("\n", "\n# ") + if self.at_nl: + data = data + "\n" + + self.f.write(data) + + def flush(self): + return self.f.flush() diff --git a/test/infamy/topologies/1x1.dot b/test/infamy/topologies/1x1.dot new file mode 100644 index 00000000..b995e772 --- /dev/null +++ b/test/infamy/topologies/1x1.dot @@ -0,0 +1,23 @@ +graph "1x1" { + layout="neato"; + overlap="false"; + esep="+20"; + + node [shape=record, fontname="monospace"]; + edge [color="cornflowerblue", penwidth="2"]; + + host [ + label="host | { tgt }", + pos="0,12!", + kind="controller", + ]; + + target [ + label="{ mgmt } | target", + pos="10,12!", + + kind="infix", + ]; + + host:tgt -- target:mgmt +} diff --git a/test/infamy/topologies/1x2.dot b/test/infamy/topologies/1x2.dot new file mode 100644 index 00000000..3e958dbf --- /dev/null +++ b/test/infamy/topologies/1x2.dot @@ -0,0 +1,24 @@ +graph "1x2" { + layout="neato"; + overlap="false"; + esep="+20"; + + node [shape=record, fontname="monospace"]; + edge [color="cornflowerblue", penwidth="2"]; + + host [ + label="host | { tgt | | data }", + pos="0,12!", + kind="controller", + ]; + + target [ + label="{ mgmt | data } | target", + pos="10,12!", + + kind="infix", + ]; + + host:tgt -- target:mgmt + host:data -- target:data +} diff --git a/test/infamy/topology.py b/test/infamy/topology.py new file mode 100644 index 00000000..1814af06 --- /dev/null +++ b/test/infamy/topology.py @@ -0,0 +1,68 @@ +import networkx as nx +from networkx.algorithms import isomorphism + +def qstrip(text): + if text.startswith("\"") and text.endswith("\""): + return text[1:-1] + return text + +def match_kind(n1, n2): + return qstrip(n1["kind"]) == qstrip(n2["kind"]) + +def find_mapping(phy, log, node_match=match_kind): + def annotate(nxg, dotg): + for e in list(nxg.edges): + s, d = e + nxg.nodes[s]["kind"] = "port" + nxg.nodes[d]["kind"] = "port" + + sn, sp = s.split(":") + dn, dp = d.split(":") + + try: + sk = dotg.get_node(sn)[0].get_attributes()["kind"] + except: + raise ValueError("\"{}\"'s kind is not known".format(sn)) + + try: + dk = dotg.get_node(dn)[0].get_attributes()["kind"] + except: + raise ValueError("\"{}\"'s kind is not known".format(dn)) + + nxg.add_node(sn, kind=sk) + nxg.add_edge(sn, s) + nxg.add_node(dn, kind=dk) + nxg.add_edge(dn, d) + + phyedges = [(e.get_source(), e.get_destination()) for e in phy.get_edges()] + logedges = [(e.get_source(), e.get_destination()) for e in log.get_edges()] + + phyedges.sort() + logedges.sort() + nxphy = nx.Graph(phyedges) + nxlog = nx.Graph(logedges) + annotate(nxphy, phy) + annotate(nxlog, log) + + nxmap = isomorphism.GraphMatcher(nxphy, nxlog, node_match=node_match) + if nxmap.subgraph_is_isomorphic(): + return { v: k for (k, v) in nxmap.mapping.items() } + + return None + +# This let's us call this script like so... +# +# python3 topology.py +# +# to inspect the graph matcher's results in isolation from the rest of +# the system. +if __name__ == "__main__": + import json + import pydot + import sys + + pdot = pydot.graph_from_dot_file(sys.argv[1])[0] + ldot = pydot.graph_from_dot_file(sys.argv[2])[0] + mapping = find_mapping(pdot, ldot) + + print(json.dumps(mapping))