mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
test/infamy: Initial add of python based test library
This commit is contained in:
committed by
Joachim Wiberg
parent
9bead2fca4
commit
0fc720d48a
@@ -0,0 +1 @@
|
||||
/__pycache__
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
<filter type="subtree">
|
||||
<modules-state xmlns="{ns}" />
|
||||
</filter>""").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"<filter type=\"xpath\" select=\"{xpath}\" {xmlns} />"
|
||||
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 = "<config xmlns=\"urn:ietf:params:xml:ns:netconf:base:1.0\">" + edit + "</config>"
|
||||
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))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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> tgt }",
|
||||
pos="0,12!",
|
||||
kind="controller",
|
||||
];
|
||||
|
||||
target [
|
||||
label="{ <mgmt> mgmt } | target",
|
||||
pos="10,12!",
|
||||
|
||||
kind="infix",
|
||||
];
|
||||
|
||||
host:tgt -- target:mgmt
|
||||
}
|
||||
@@ -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> tgt | <data> | data }",
|
||||
pos="0,12!",
|
||||
kind="controller",
|
||||
];
|
||||
|
||||
target [
|
||||
label="{ <mgmt> mgmt | <data> data } | target",
|
||||
pos="10,12!",
|
||||
|
||||
kind="infix",
|
||||
];
|
||||
|
||||
host:tgt -- target:mgmt
|
||||
host:data -- target:data
|
||||
}
|
||||
@@ -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 <physical> <logical>
|
||||
#
|
||||
# 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))
|
||||
Reference in New Issue
Block a user