Commit 8d03c153 authored by Jakub Kicinski's avatar Jakub Kicinski

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.orgSigned-off-by: default avatarJakub Kicinski <kuba@kernel.org>
parents b2c8599f f1e68a1a
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
TEST_INCLUDES := $(wildcard lib/py/*.py) TEST_INCLUDES := $(wildcard lib/py/*.py)
TEST_PROGS := stats.py TEST_PROGS := \
ping.py \
stats.py \
# end of TEST_PROGS
include ../../lib.mk include ../../lib.mk
...@@ -23,8 +23,41 @@ or:: ...@@ -23,8 +23,41 @@ or::
# Variable set in a file # Variable set in a file
NETIF=eth0 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 NETIF
~~~~~ ~~~~~
Name of the netdevice against which the test should be executed. Name of the netdevice against which the test should be executed.
When empty or not set software devices will be used. 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: ...@@ -15,3 +15,4 @@ except ModuleNotFoundError as e:
sys.exit(4) sys.exit(4)
from .env import * from .env import *
from .remote import Remote
...@@ -3,15 +3,41 @@ ...@@ -3,15 +3,41 @@
import os import os
import shlex import shlex
from pathlib import Path from pathlib import Path
from lib.py import ip from lib.py import KsftSkipEx
from lib.py import NetdevSimDev 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 NetDrvEnv:
"""
Class for a single NIC / host env, with no remote end
"""
def __init__(self, src_path): def __init__(self, src_path):
self._ns = None self._ns = None
self.env = os.environ.copy() self.env = _load_env_file(src_path)
self._load_env_file(src_path)
if 'NETIF' in self.env: if 'NETIF' in self.env:
self.dev = ip("link show dev " + self.env['NETIF'], json=True)[0] self.dev = ip("link show dev " + self.env['NETIF'], json=True)[0]
...@@ -34,19 +60,130 @@ class NetDrvEnv: ...@@ -34,19 +60,130 @@ class NetDrvEnv:
self._ns.remove() self._ns.remove()
self._ns = None self._ns = None
def _load_env_file(self, src_path):
src_dir = Path(src_path).parent.resolve() class NetDrvEpEnv:
if not (src_dir / "net.config").exists(): """
return Class for an environment with a local device and "remote endpoint"
which can be used to send traffic in.
lexer = shlex.shlex(open((src_dir / "net.config").as_posix(), 'r').read())
k = None For local testing it creates two network namespaces and a pair
for token in lexer: of netdevsim devices.
if k is None: """
k = token
self.env[k] = "" # Network prefixes used for local tests
elif token == "=": nsim_v4_pfx = "192.0.2."
pass nsim_v6_pfx = "2001:db8::"
else:
self.env[k] = token def __init__(self, src_path):
k = None
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)
# 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)
# 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)
# 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
#!/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 @@ ...@@ -2,6 +2,7 @@
from .consts import KSRC from .consts import KSRC
from .ksft import * from .ksft import *
from .netns import NetNS
from .nsim import * from .nsim import *
from .utils import * from .utils import *
from .ynl import NlError, YnlFamily, EthtoolFamily, NetdevFamily, RtnlFamily from .ynl import NlError, YnlFamily, EthtoolFamily, NetdevFamily, RtnlFamily
...@@ -99,7 +99,18 @@ def ktap_result(ok, cnt=1, case="", comment=""): ...@@ -99,7 +99,18 @@ def ktap_result(ok, cnt=1, case="", comment=""):
print(res) 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} totals = {"pass": 0, "fail": 0, "skip": 0, "xfail": 0}
print("KTAP version 1") print("KTAP version 1")
......
# 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})"
# SPDX-License-Identifier: GPL-2.0 # SPDX-License-Identifier: GPL-2.0
import json as _json import json as _json
import random
import re
import subprocess import subprocess
import time
class cmd: 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 ns:
if isinstance(ns, NetNS):
ns = ns.name
comm = f'ip netns exec {ns} ' + comm comm = f'ip netns exec {ns} ' + comm
self.stdout = None self.stdout = None
...@@ -15,15 +17,18 @@ class cmd: ...@@ -15,15 +17,18 @@ class cmd:
self.ret = None self.ret = None
self.comm = comm self.comm = comm
self.proc = subprocess.Popen(comm, shell=shell, stdout=subprocess.PIPE, if host:
stderr=subprocess.PIPE) self.proc = host.cmd(comm)
else:
self.proc = subprocess.Popen(comm, shell=shell, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if not background: if not background:
self.process(terminate=False, fail=fail) self.process(terminate=False, fail=fail)
def process(self, terminate=True, fail=None): def process(self, terminate=True, fail=None):
if terminate: if terminate:
self.proc.terminate() self.proc.terminate()
stdout, stderr = self.proc.communicate() stdout, stderr = self.proc.communicate(timeout=5)
self.stdout = stdout.decode("utf-8") self.stdout = stdout.decode("utf-8")
self.stderr = stderr.decode("utf-8") self.stderr = stderr.decode("utf-8")
self.proc.stdout.close() self.proc.stdout.close()
...@@ -37,12 +42,51 @@ class cmd: ...@@ -37,12 +42,51 @@ class cmd:
(self.proc.args, stdout, stderr)) (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 " cmd_str = "ip "
if json: if json:
cmd_str += '-j ' cmd_str += '-j '
cmd_str += args cmd_str += args
cmd_obj = cmd(cmd_str, ns=ns) cmd_obj = cmd(cmd_str, ns=ns, host=host)
if json: if json:
return _json.loads(cmd_obj.stdout) return _json.loads(cmd_obj.stdout)
return cmd_obj 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)
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment