Merge branch 'selftests-drv-net-support-testing-with-a-remote-system'
Jakub Kicinski says: ==================== selftests: drv-net: support testing with a remote system Implement support for tests which require access to a remote system / endpoint which can generate traffic. This series concludes the "groundwork" for upstream driver tests. I wanted to support the three models which came up in discussions: - SW testing with netdevsim - "local" testing with two ports on the same system in a loopback - "remote" testing via SSH so there is a tiny bit of an abstraction which wraps up how "remote" commands are executed. Otherwise hopefully there's nothing surprising. I'm only adding a ping test. I had a bigger one written but I was worried we'll get into discussing the details of the test itself and how I chose to hack up netdevsim, instead of the test infra... So that test will be a follow up :) v4: https://lore.kernel.org/all/20240418233844.2762396-1-kuba@kernel.org v3: https://lore.kernel.org/all/20240417231146.2435572-1-kuba@kernel.org v2: https://lore.kernel.org/all/20240416004556.1618804-1-kuba@kernel.org v1: https://lore.kernel.org/all/20240412233705.1066444-1-kuba@kernel.org ==================== Link: https://lore.kernel.org/r/20240420025237.3309296-1-kuba@kernel.org Signed-off-by: Jakub Kicinski <kuba@kernel.org>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
TEST_INCLUDES := $(wildcard lib/py/*.py)
|
||||
|
||||
TEST_PROGS := stats.py
|
||||
TEST_PROGS := \
|
||||
ping.py \
|
||||
stats.py \
|
||||
# end of TEST_PROGS
|
||||
|
||||
include ../../lib.mk
|
||||
|
||||
@@ -23,8 +23,41 @@ or::
|
||||
# Variable set in a file
|
||||
NETIF=eth0
|
||||
|
||||
Please note that the config parser is very simple, if there are
|
||||
any non-alphanumeric characters in the value it needs to be in
|
||||
double quotes.
|
||||
|
||||
NETIF
|
||||
~~~~~
|
||||
|
||||
Name of the netdevice against which the test should be executed.
|
||||
When empty or not set software devices will be used.
|
||||
|
||||
LOCAL_V4, LOCAL_V6, REMOTE_V4, REMOTE_V6
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Local and remote endpoint IP addresses.
|
||||
|
||||
REMOTE_TYPE
|
||||
~~~~~~~~~~~
|
||||
|
||||
Communication method used to run commands on the remote endpoint.
|
||||
Test framework has built-in support for ``netns`` and ``ssh`` channels.
|
||||
``netns`` assumes the "remote" interface is part of the same
|
||||
host, just moved to the specified netns.
|
||||
``ssh`` communicates with remote endpoint over ``ssh`` and ``scp``.
|
||||
Using persistent SSH connections is strongly encouraged to avoid
|
||||
the latency of SSH connection setup on every command.
|
||||
|
||||
Communication methods are defined by classes in ``lib/py/remote_{name}.py``.
|
||||
It should be possible to add a new method without modifying any of
|
||||
the framework, by simply adding an appropriately named file to ``lib/py``.
|
||||
|
||||
REMOTE_ARGS
|
||||
~~~~~~~~~~~
|
||||
|
||||
Arguments used to construct the communication channel.
|
||||
Communication channel dependent::
|
||||
|
||||
for netns - name of the "remote" namespace
|
||||
for ssh - name/address of the remote host
|
||||
|
||||
@@ -15,3 +15,4 @@ except ModuleNotFoundError as e:
|
||||
sys.exit(4)
|
||||
|
||||
from .env import *
|
||||
from .remote import Remote
|
||||
|
||||
@@ -3,15 +3,41 @@
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from lib.py import ip
|
||||
from lib.py import NetdevSimDev
|
||||
from lib.py import KsftSkipEx
|
||||
from lib.py import cmd, ip
|
||||
from lib.py import NetNS, NetdevSimDev
|
||||
from .remote import Remote
|
||||
|
||||
|
||||
def _load_env_file(src_path):
|
||||
env = os.environ.copy()
|
||||
|
||||
src_dir = Path(src_path).parent.resolve()
|
||||
if not (src_dir / "net.config").exists():
|
||||
return env
|
||||
|
||||
lexer = shlex.shlex(open((src_dir / "net.config").as_posix(), 'r').read())
|
||||
k = None
|
||||
for token in lexer:
|
||||
if k is None:
|
||||
k = token
|
||||
env[k] = ""
|
||||
elif token == "=":
|
||||
pass
|
||||
else:
|
||||
env[k] = token
|
||||
k = None
|
||||
return env
|
||||
|
||||
|
||||
class NetDrvEnv:
|
||||
"""
|
||||
Class for a single NIC / host env, with no remote end
|
||||
"""
|
||||
def __init__(self, src_path):
|
||||
self._ns = None
|
||||
|
||||
self.env = os.environ.copy()
|
||||
self._load_env_file(src_path)
|
||||
self.env = _load_env_file(src_path)
|
||||
|
||||
if 'NETIF' in self.env:
|
||||
self.dev = ip("link show dev " + self.env['NETIF'], json=True)[0]
|
||||
@@ -34,19 +60,130 @@ class NetDrvEnv:
|
||||
self._ns.remove()
|
||||
self._ns = None
|
||||
|
||||
def _load_env_file(self, src_path):
|
||||
src_dir = Path(src_path).parent.resolve()
|
||||
if not (src_dir / "net.config").exists():
|
||||
return
|
||||
|
||||
lexer = shlex.shlex(open((src_dir / "net.config").as_posix(), 'r').read())
|
||||
k = None
|
||||
for token in lexer:
|
||||
if k is None:
|
||||
k = token
|
||||
self.env[k] = ""
|
||||
elif token == "=":
|
||||
pass
|
||||
else:
|
||||
self.env[k] = token
|
||||
k = None
|
||||
class NetDrvEpEnv:
|
||||
"""
|
||||
Class for an environment with a local device and "remote endpoint"
|
||||
which can be used to send traffic in.
|
||||
|
||||
For local testing it creates two network namespaces and a pair
|
||||
of netdevsim devices.
|
||||
"""
|
||||
|
||||
# Network prefixes used for local tests
|
||||
nsim_v4_pfx = "192.0.2."
|
||||
nsim_v6_pfx = "2001:db8::"
|
||||
|
||||
def __init__(self, src_path):
|
||||
|
||||
self.env = _load_env_file(src_path)
|
||||
|
||||
# Things we try to destroy
|
||||
self.remote = None
|
||||
# These are for local testing state
|
||||
self._netns = None
|
||||
self._ns = None
|
||||
self._ns_peer = None
|
||||
|
||||
if "NETIF" in self.env:
|
||||
self.dev = ip("link show dev " + self.env['NETIF'], json=True)[0]
|
||||
|
||||
self.v4 = self.env.get("LOCAL_V4")
|
||||
self.v6 = self.env.get("LOCAL_V6")
|
||||
self.remote_v4 = self.env.get("REMOTE_V4")
|
||||
self.remote_v6 = self.env.get("REMOTE_V6")
|
||||
kind = self.env["REMOTE_TYPE"]
|
||||
args = self.env["REMOTE_ARGS"]
|
||||
else:
|
||||
self.create_local()
|
||||
|
||||
self.dev = self._ns.nsims[0].dev
|
||||
|
||||
self.v4 = self.nsim_v4_pfx + "1"
|
||||
self.v6 = self.nsim_v6_pfx + "1"
|
||||
self.remote_v4 = self.nsim_v4_pfx + "2"
|
||||
self.remote_v6 = self.nsim_v6_pfx + "2"
|
||||
kind = "netns"
|
||||
args = self._netns.name
|
||||
|
||||
self.remote = Remote(kind, args, src_path)
|
||||
|
||||
self.addr = self.v6 if self.v6 else self.v4
|
||||
self.remote_addr = self.remote_v6 if self.remote_v6 else self.remote_v4
|
||||
|
||||
self.addr_ipver = "6" if self.v6 else "4"
|
||||
# Bracketed addresses, some commands need IPv6 to be inside []
|
||||
self.baddr = f"[{self.v6}]" if self.v6 else self.v4
|
||||
self.remote_baddr = f"[{self.remote_v6}]" if self.remote_v6 else self.remote_v4
|
||||
|
||||
self.ifname = self.dev['ifname']
|
||||
self.ifindex = self.dev['ifindex']
|
||||
|
||||
self._required_cmd = {}
|
||||
|
||||
def create_local(self):
|
||||
self._netns = NetNS()
|
||||
self._ns = NetdevSimDev()
|
||||
self._ns_peer = NetdevSimDev(ns=self._netns)
|
||||
|
||||
with open("/proc/self/ns/net") as nsfd0, \
|
||||
open("/var/run/netns/" + self._netns.name) as nsfd1:
|
||||
ifi0 = self._ns.nsims[0].ifindex
|
||||
ifi1 = self._ns_peer.nsims[0].ifindex
|
||||
NetdevSimDev.ctrl_write('link_device',
|
||||
f'{nsfd0.fileno()}:{ifi0} {nsfd1.fileno()}:{ifi1}')
|
||||
|
||||
ip(f" addr add dev {self._ns.nsims[0].ifname} {self.nsim_v4_pfx}1/24")
|
||||
ip(f"-6 addr add dev {self._ns.nsims[0].ifname} {self.nsim_v6_pfx}1/64 nodad")
|
||||
ip(f" link set dev {self._ns.nsims[0].ifname} up")
|
||||
|
||||
ip(f" addr add dev {self._ns_peer.nsims[0].ifname} {self.nsim_v4_pfx}2/24", ns=self._netns)
|
||||
ip(f"-6 addr add dev {self._ns_peer.nsims[0].ifname} {self.nsim_v6_pfx}2/64 nodad", ns=self._netns)
|
||||
ip(f" link set dev {self._ns_peer.nsims[0].ifname} up", ns=self._netns)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, ex_type, ex_value, ex_tb):
|
||||
"""
|
||||
__exit__ gets called at the end of a "with" block.
|
||||
"""
|
||||
self.__del__()
|
||||
|
||||
def __del__(self):
|
||||
if self._ns:
|
||||
self._ns.remove()
|
||||
self._ns = None
|
||||
if self._ns_peer:
|
||||
self._ns_peer.remove()
|
||||
self._ns_peer = None
|
||||
if self._netns:
|
||||
del self._netns
|
||||
self._netns = None
|
||||
if self.remote:
|
||||
del self.remote
|
||||
self.remote = None
|
||||
|
||||
def require_v4(self):
|
||||
if not self.v4 or not self.remote_v4:
|
||||
raise KsftSkipEx("Test requires IPv4 connectivity")
|
||||
|
||||
def require_v6(self):
|
||||
if not self.v6 or not self.remote_v6:
|
||||
raise KsftSkipEx("Test requires IPv6 connectivity")
|
||||
|
||||
def _require_cmd(self, comm, key, host=None):
|
||||
cached = self._required_cmd.get(comm, {})
|
||||
if cached.get(key) is None:
|
||||
cached[key] = cmd("command -v -- " + comm, fail=False,
|
||||
shell=True, host=host).ret == 0
|
||||
self._required_cmd[comm] = cached
|
||||
return cached[key]
|
||||
|
||||
def require_cmd(self, comm, local=True, remote=False):
|
||||
if local:
|
||||
if not self._require_cmd(comm, "local"):
|
||||
raise KsftSkipEx("Test requires command: " + comm)
|
||||
if remote:
|
||||
if not self._require_cmd(comm, "remote"):
|
||||
raise KsftSkipEx("Test requires (remote) command: " + comm)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
import os
|
||||
import importlib
|
||||
|
||||
_modules = {}
|
||||
|
||||
def Remote(kind, args, src_path):
|
||||
global _modules
|
||||
|
||||
if kind not in _modules:
|
||||
_modules[kind] = importlib.import_module("..remote_" + kind, __name__)
|
||||
|
||||
dir_path = os.path.abspath(src_path + "/../")
|
||||
return getattr(_modules[kind], "Remote")(args, dir_path)
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from lib.py import cmd
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, name, dir_path):
|
||||
self.name = name
|
||||
self.dir_path = dir_path
|
||||
|
||||
def cmd(self, comm):
|
||||
return subprocess.Popen(["ip", "netns", "exec", self.name, "bash", "-c", comm],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def deploy(self, what):
|
||||
if os.path.isabs(what):
|
||||
return what
|
||||
return os.path.abspath(self.dir_path + "/" + what)
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
import os
|
||||
import string
|
||||
import subprocess
|
||||
import random
|
||||
|
||||
from lib.py import cmd
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, name, dir_path):
|
||||
self.name = name
|
||||
self.dir_path = dir_path
|
||||
self._tmpdir = None
|
||||
|
||||
def __del__(self):
|
||||
if self._tmpdir:
|
||||
cmd("rm -rf " + self._tmpdir, host=self)
|
||||
self._tmpdir = None
|
||||
|
||||
def cmd(self, comm):
|
||||
return subprocess.Popen(["ssh", "-q", self.name, comm],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
def _mktmp(self):
|
||||
return ''.join(random.choice(string.ascii_lowercase) for _ in range(8))
|
||||
|
||||
def deploy(self, what):
|
||||
if not self._tmpdir:
|
||||
self._tmpdir = "/tmp/" + self._mktmp()
|
||||
cmd("mkdir " + self._tmpdir, host=self)
|
||||
file_name = self._tmpdir + "/" + self._mktmp() + os.path.basename(what)
|
||||
|
||||
if not os.path.isabs(what):
|
||||
what = os.path.abspath(self.dir_path + "/" + what)
|
||||
|
||||
cmd(f"scp {what} {self.name}:{file_name}")
|
||||
return file_name
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
from lib.py import ksft_run, ksft_exit
|
||||
from lib.py import ksft_eq
|
||||
from lib.py import NetDrvEpEnv
|
||||
from lib.py import bkg, cmd, wait_port_listen, rand_port
|
||||
|
||||
|
||||
def test_v4(cfg) -> None:
|
||||
cfg.require_v4()
|
||||
|
||||
cmd(f"ping -c 1 -W0.5 {cfg.remote_v4}")
|
||||
cmd(f"ping -c 1 -W0.5 {cfg.v4}", host=cfg.remote)
|
||||
|
||||
|
||||
def test_v6(cfg) -> None:
|
||||
cfg.require_v6()
|
||||
|
||||
cmd(f"ping -c 1 -W0.5 {cfg.remote_v6}")
|
||||
cmd(f"ping -c 1 -W0.5 {cfg.v6}", host=cfg.remote)
|
||||
|
||||
|
||||
def test_tcp(cfg) -> None:
|
||||
cfg.require_cmd("socat", remote=True)
|
||||
|
||||
port = rand_port()
|
||||
listen_cmd = f"socat -{cfg.addr_ipver} -t 2 -u TCP-LISTEN:{port},reuseport STDOUT"
|
||||
|
||||
with bkg(listen_cmd, exit_wait=True) as nc:
|
||||
wait_port_listen(port)
|
||||
|
||||
cmd(f"echo ping | socat -t 2 -u STDIN TCP:{cfg.baddr}:{port}",
|
||||
shell=True, host=cfg.remote)
|
||||
ksft_eq(nc.stdout.strip(), "ping")
|
||||
|
||||
with bkg(listen_cmd, host=cfg.remote, exit_wait=True) as nc:
|
||||
wait_port_listen(port, host=cfg.remote)
|
||||
|
||||
cmd(f"echo ping | socat -t 2 -u STDIN TCP:{cfg.remote_baddr}:{port}", shell=True)
|
||||
ksft_eq(nc.stdout.strip(), "ping")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with NetDrvEpEnv(__file__) as cfg:
|
||||
ksft_run(globs=globals(), case_pfx={"test_"}, args=(cfg, ))
|
||||
ksft_exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .consts import KSRC
|
||||
from .ksft import *
|
||||
from .netns import NetNS
|
||||
from .nsim import *
|
||||
from .utils import *
|
||||
from .ynl import NlError, YnlFamily, EthtoolFamily, NetdevFamily, RtnlFamily
|
||||
|
||||
@@ -99,7 +99,18 @@ def ktap_result(ok, cnt=1, case="", comment=""):
|
||||
print(res)
|
||||
|
||||
|
||||
def ksft_run(cases, args=()):
|
||||
def ksft_run(cases=None, globs=None, case_pfx=None, args=()):
|
||||
cases = cases or []
|
||||
|
||||
if globs and case_pfx:
|
||||
for key, value in globs.items():
|
||||
if not callable(value):
|
||||
continue
|
||||
for prefix in case_pfx:
|
||||
if key.startswith(prefix):
|
||||
cases.append(value)
|
||||
break
|
||||
|
||||
totals = {"pass": 0, "fail": 0, "skip": 0, "xfail": 0}
|
||||
|
||||
print("KTAP version 1")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
from .utils import ip
|
||||
import random
|
||||
import string
|
||||
|
||||
|
||||
class NetNS:
|
||||
def __init__(self, name=None):
|
||||
if name:
|
||||
self.name = name
|
||||
else:
|
||||
self.name = ''.join(random.choice(string.ascii_lowercase) for _ in range(8))
|
||||
ip('netns add ' + self.name)
|
||||
|
||||
def __del__(self):
|
||||
if self.name:
|
||||
ip('netns del ' + self.name)
|
||||
self.name = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, ex_type, ex_value, ex_tb):
|
||||
self.__del__()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def __repr__(self):
|
||||
return f"NetNS({self.name})"
|
||||
@@ -1,13 +1,15 @@
|
||||
# SPDX-License-Identifier: GPL-2.0
|
||||
|
||||
import json as _json
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class cmd:
|
||||
def __init__(self, comm, shell=True, fail=True, ns=None, background=False):
|
||||
def __init__(self, comm, shell=True, fail=True, ns=None, background=False, host=None):
|
||||
if ns:
|
||||
if isinstance(ns, NetNS):
|
||||
ns = ns.name
|
||||
comm = f'ip netns exec {ns} ' + comm
|
||||
|
||||
self.stdout = None
|
||||
@@ -15,15 +17,18 @@ class cmd:
|
||||
self.ret = None
|
||||
|
||||
self.comm = comm
|
||||
self.proc = subprocess.Popen(comm, shell=shell, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
if host:
|
||||
self.proc = host.cmd(comm)
|
||||
else:
|
||||
self.proc = subprocess.Popen(comm, shell=shell, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
if not background:
|
||||
self.process(terminate=False, fail=fail)
|
||||
|
||||
def process(self, terminate=True, fail=None):
|
||||
if terminate:
|
||||
self.proc.terminate()
|
||||
stdout, stderr = self.proc.communicate()
|
||||
stdout, stderr = self.proc.communicate(timeout=5)
|
||||
self.stdout = stdout.decode("utf-8")
|
||||
self.stderr = stderr.decode("utf-8")
|
||||
self.proc.stdout.close()
|
||||
@@ -37,12 +42,51 @@ class cmd:
|
||||
(self.proc.args, stdout, stderr))
|
||||
|
||||
|
||||
def ip(args, json=None, ns=None):
|
||||
class bkg(cmd):
|
||||
def __init__(self, comm, shell=True, fail=True, ns=None, host=None,
|
||||
exit_wait=False):
|
||||
super().__init__(comm, background=True,
|
||||
shell=shell, fail=fail, ns=ns, host=host)
|
||||
self.terminate = not exit_wait
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, ex_type, ex_value, ex_tb):
|
||||
return self.process(terminate=self.terminate)
|
||||
|
||||
|
||||
def ip(args, json=None, ns=None, host=None):
|
||||
cmd_str = "ip "
|
||||
if json:
|
||||
cmd_str += '-j '
|
||||
cmd_str += args
|
||||
cmd_obj = cmd(cmd_str, ns=ns)
|
||||
cmd_obj = cmd(cmd_str, ns=ns, host=host)
|
||||
if json:
|
||||
return _json.loads(cmd_obj.stdout)
|
||||
return cmd_obj
|
||||
|
||||
|
||||
def rand_port():
|
||||
"""
|
||||
Get unprivileged port, for now just random, one day we may decide to check if used.
|
||||
"""
|
||||
return random.randint(1024, 65535)
|
||||
|
||||
|
||||
def wait_port_listen(port, proto="tcp", ns=None, host=None, sleep=0.005, deadline=5):
|
||||
end = time.monotonic() + deadline
|
||||
|
||||
pattern = f":{port:04X} .* "
|
||||
if proto == "tcp": # for tcp protocol additionally check the socket state
|
||||
pattern += "0A"
|
||||
pattern = re.compile(pattern)
|
||||
|
||||
while True:
|
||||
data = cmd(f'cat /proc/net/{proto}*', ns=ns, host=host, shell=True).stdout
|
||||
for row in data.split("\n"):
|
||||
if pattern.search(row):
|
||||
return
|
||||
if time.monotonic() > end:
|
||||
raise Exception("Waiting for port listen timed out")
|
||||
time.sleep(sleep)
|
||||
|
||||
Reference in New Issue
Block a user