Here's another draft of my work on testing passt with Avocado and the exeter library I recently created. It includes Cleber's patch adding some basic Avocado tests and builds on that. This draft is IMO significantly less janky than the last one, but certainly not jank-free: * We create the Avocado job files from the exeter sources in the Makefile. Ideally Avocado would eventually be extended to handle this itself * The names that Avocado sees for each test aren't great Stefano, If you could look particularly at 7/15 and 15/15 which add the real tests for passt/pasta, that would be great. The more specific you can be about what you find ugly about how the tests are written, then better I can try to address that. I suspect it will be easier to actually apply the series, then look at the new test files (test/build/build.py, and test/pasta/pasta.py particularly). From there you can look at as much of the support library as you need to, rather than digging through the actual patches to look for that. Cleber, If you could look at 4..7/22 particularly to review how I'm connecting the actual tests to the Avocado runner, that would be helpful. Changes since v2: * Added mypy type checking throughout * Use exeter "scenarios" to reduce boilerplate * Folded together a number of closely related patches (from 22 patches down to 15) * Entirely avoid open-coded Python context managers in favour of contextlib.contextmanager * No longer accidentally run a lot of the "meta" tests in the "real" testsuite * So, so many assorted cleanups Cleber Rosa (1): test: run static checkers with Avocado and JSON definitions David Gibson (14): test: Adjust how we invoke tests with run_avocado test: Extend make targets to run Avocado tests test: Exeter based static tests tasst: Support library and linters for tests in Python tasst/cmdsite: Base helpers for running shell commands in various places test: Add exeter+Avocado based build tests tasst/unshare: Add helpers to run commands in Linux unshared namespaces tasst/ip: Helpers for configuring IPv4 and IPv6 tasst/veth: Helpers for constructing veth devices between namespaces tasst: Helpers to test transferring data between sites tasst: Helpers for testing NDP behaviour tasst: Helpers for testing DHCP & DHCPv6 behaviour tasst: Helpers to construct a simple network environment for tests avocado: Convert basic pasta tests test/.gitignore | 2 + test/Makefile | 59 +++++++- test/avocado/static_checkers.json | 12 ++ test/build/.gitignore | 2 + test/build/build.py | 86 +++++++++++ test/build/static_checkers.sh | 28 ++++ test/meta/.gitignore | 1 + test/meta/lint.sh | 28 ++++ test/pasta/.gitignore | 1 + test/pasta/pasta.py | 130 +++++++++++++++++ test/run_avocado | 52 +++++++ test/tasst/.gitignore | 1 + test/tasst/__init__.py | 11 ++ test/tasst/__main__.py | 40 +++++ test/tasst/cmdsite.py | 193 ++++++++++++++++++++++++ test/tasst/dhcp.py | 190 ++++++++++++++++++++++++ test/tasst/ip.py | 234 ++++++++++++++++++++++++++++++ test/tasst/ndp.py | 106 ++++++++++++++ test/tasst/pasta.py | 48 ++++++ test/tasst/scenario/__init__.py | 12 ++ test/tasst/scenario/simple.py | 108 ++++++++++++++ test/tasst/selftest/__init__.py | 16 ++ test/tasst/transfer.py | 193 ++++++++++++++++++++++++ test/tasst/unshare.py | 166 +++++++++++++++++++++ test/tasst/veth.py | 104 +++++++++++++ 25 files changed, 1822 insertions(+), 1 deletion(-) create mode 100644 test/avocado/static_checkers.json create mode 100644 test/build/.gitignore create mode 100644 test/build/build.py create mode 100644 test/build/static_checkers.sh create mode 100644 test/meta/.gitignore create mode 100644 test/meta/lint.sh create mode 100644 test/pasta/.gitignore create mode 100644 test/pasta/pasta.py create mode 100755 test/run_avocado create mode 100644 test/tasst/.gitignore create mode 100644 test/tasst/__init__.py create mode 100644 test/tasst/__main__.py create mode 100644 test/tasst/cmdsite.py create mode 100644 test/tasst/dhcp.py create mode 100644 test/tasst/ip.py create mode 100644 test/tasst/ndp.py create mode 100644 test/tasst/pasta.py create mode 100644 test/tasst/scenario/__init__.py create mode 100644 test/tasst/scenario/simple.py create mode 100644 test/tasst/selftest/__init__.py create mode 100644 test/tasst/transfer.py create mode 100644 test/tasst/unshare.py create mode 100644 test/tasst/veth.py -- 2.46.0
From: Cleber Rosa <crosa(a)redhat.com> This adds a script and configuration to use the Avocado Testing Framework to run, at this time, the static checkers. The actual tests are defined using (JSON based) files, that are known to Avocado as "recipes". The JSON files are parsed and "resolved" into tests by Avocado's "runnables-recipe" resolver. The syntax allows for any kind of test supported by Avocado to be defined there, including a mix of different test types. By the nature of Avocado's default configuration, those will run in parallel in the host system. For more complex tests or different use cases, Avocado could help in future versions by running those in different environments such as containers. The entry point ("test/run_avocado") is intended to be an optional tool at this point, coexisting with the current implementation to run tests. It uses Avocado's Job API to create a job with, at this point, the static checkers suite. The installation of Avocado itself is left to users, given that the details on how to install it (virtual environments and specific tooling) can be a very different and long discussion. Signed-off-by: Cleber Rosa <crosa(a)redhat.com> Message-ID: <20240629121342.3284907-1-crosa(a)redhat.com> --- test/avocado/static_checkers.json | 16 ++++++++++ test/run_avocado | 49 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/avocado/static_checkers.json create mode 100755 test/run_avocado diff --git a/test/avocado/static_checkers.json b/test/avocado/static_checkers.json new file mode 100644 index 00000000..5fae43ed --- /dev/null +++ b/test/avocado/static_checkers.json @@ -0,0 +1,16 @@ +[ + { + "kind": "exec-test", + "uri": "make", + "args": [ + "clang-tidy" + ] + }, + { + "kind": "exec-test", + "uri": "make", + "args": [ + "cppcheck" + ] + } +] diff --git a/test/run_avocado b/test/run_avocado new file mode 100755 index 00000000..37db17c3 --- /dev/null +++ b/test/run_avocado @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import os +import sys + + +def check_avocado_version(): + minimum_version = 106.0 + + def error_out(): + print( + f"Avocado version {minimum_version} or later is required.\n" + f"You may install it with: \n" + f" python3 -m pip install avocado-framework", + file=sys.stderr, + ) + sys.exit(1) + + try: + from avocado import VERSION + + if (float(VERSION)) < minimum_version: + error_out() + except ImportError: + error_out() + + +check_avocado_version() +from avocado.core.job import Job +from avocado.core.suite import TestSuite + + +def main(): + repo_root_path = os.path.abspath( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + ) + config = { + "resolver.references": [ + os.path.join(repo_root_path, "test", "avocado", "static_checkers.json") + ], + "runner.identifier_format": "{args[0]}", + } + suite = TestSuite.from_config(config, name="static_checkers") + with Job(config, [suite]) as j: + return j.run() + + +if __name__ == "__main__": + sys.exit(main()) -- 2.46.0
Currently the Avocado test cases expect to be run from the base dir of the passt repo. At least for the time being, it turns out to be more convenient to structure the tests to run from the test/ subdirectory. So, adjust them to do so. We make some changes to run_avocado to work better with this too: * It appeared to have one too many os.path.dirname() calls, so it set repo_root_path to the parent of the passt tree, rather than the tree itself * We add an os.chdir(), so the tests will be invoked from the test directory regardles of where we invoke run_avocado * We adjust the runner.identifier config parameter so we get distinct (although very verbose) names for the more complex tests we're going to add. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/avocado/static_checkers.json | 8 ++------ test/run_avocado | 12 +++++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/avocado/static_checkers.json b/test/avocado/static_checkers.json index 5fae43ed..480b2461 100644 --- a/test/avocado/static_checkers.json +++ b/test/avocado/static_checkers.json @@ -2,15 +2,11 @@ { "kind": "exec-test", "uri": "make", - "args": [ - "clang-tidy" - ] + "args": ["-C", "..", "clang-tidy"] }, { "kind": "exec-test", "uri": "make", - "args": [ - "cppcheck" - ] + "args": ["-C", "..", "cppcheck"] } ] diff --git a/test/run_avocado b/test/run_avocado index 37db17c3..2c8822c6 100755 --- a/test/run_avocado +++ b/test/run_avocado @@ -31,14 +31,16 @@ from avocado.core.suite import TestSuite def main(): - repo_root_path = os.path.abspath( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - ) + repo_root_path = os.path.dirname(os.path.dirname(__file__)) + test_root_path = os.path.join(repo_root_path, "test") + + os.chdir(test_root_path) + config = { "resolver.references": [ - os.path.join(repo_root_path, "test", "avocado", "static_checkers.json") + os.path.join(test_root_path, "avocado", "static_checkers.json") ], - "runner.identifier_format": "{args[0]}", + "runner.identifier_format": "{args}", } suite = TestSuite.from_config(config, name="static_checkers") with Job(config, [suite]) as j: -- 2.46.0
Add a new 'avocado' target to the test/ Makefile, which will install avocado into a Python venv, and run the Avocado based tests with it. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/.gitignore | 1 + test/Makefile | 17 +++++++++++++++++ test/run_avocado | 9 +++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/test/.gitignore b/test/.gitignore index 6dd4790b..a79d5b6f 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -10,3 +10,4 @@ QEMU_EFI.fd nstool guest-key guest-key.pub +/venv/ diff --git a/test/Makefile b/test/Makefile index 35a3b559..4bf971f7 100644 --- a/test/Makefile +++ b/test/Makefile @@ -63,6 +63,13 @@ LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ ASSETS = $(DOWNLOAD_ASSETS) $(LOCAL_ASSETS) +AVOCADO_JOBS = avocado/static_checkers.json + +PYTHON = python3 +VENV = venv +PIP = $(VENV)/bin/pip3 +RUN_AVOCADO = $(VENV)/bin/python3 run_avocado + CFLAGS = -Wall -Werror -Wextra -pedantic -std=c99 assets: $(ASSETS) @@ -116,6 +123,15 @@ medium.bin: big.bin: dd if=/dev/urandom bs=1M count=10 of=$@ +.PHONY: venv +venv: + $(PYTHON) -m venv $(VENV) + $(PIP) install avocado-framework + +.PHONY: avocado +avocado: venv + $(RUN_AVOCADO) all $(AVOCADO_JOBS) + check: assets ./run @@ -127,6 +143,7 @@ clean: rm -f $(LOCAL_ASSETS) rm -rf test_logs rm -f prepared-*.qcow2 prepared-*.img + rm -rf $(VENV) realclean: clean rm -rf $(DOWNLOAD_ASSETS) diff --git a/test/run_avocado b/test/run_avocado index 2c8822c6..9ee13a0f 100755 --- a/test/run_avocado +++ b/test/run_avocado @@ -34,15 +34,16 @@ def main(): repo_root_path = os.path.dirname(os.path.dirname(__file__)) test_root_path = os.path.join(repo_root_path, "test") + suitename = sys.argv[1] + references = [os.path.join(test_root_path, x) for x in sys.argv[2:]] + os.chdir(test_root_path) config = { - "resolver.references": [ - os.path.join(test_root_path, "avocado", "static_checkers.json") - ], + "resolver.references": references, "runner.identifier_format": "{args}", } - suite = TestSuite.from_config(config, name="static_checkers") + suite = TestSuite.from_config(config, name=suitename) with Job(config, [suite]) as j: return j.run() -- 2.46.0
Introduce some trivial testcases based on the exeter library. These run the C static checkers, which is redundant with the included Avocado json file, but are useful as an example. We extend the make avocado target to generate Avocado job files from the exeter tests and include them in the test run. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/.gitignore | 1 + test/Makefile | 16 +++++++++++++--- test/build/.gitignore | 1 + test/build/static_checkers.sh | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 test/build/.gitignore create mode 100644 test/build/static_checkers.sh diff --git a/test/.gitignore b/test/.gitignore index a79d5b6f..bded349b 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -11,3 +11,4 @@ nstool guest-key guest-key.pub /venv/ +/exeter/ diff --git a/test/Makefile b/test/Makefile index 4bf971f7..d5a0f776 100644 --- a/test/Makefile +++ b/test/Makefile @@ -52,7 +52,7 @@ UBUNTU_NEW_IMGS = xenial-server-cloudimg-powerpc-disk1.img \ jammy-server-cloudimg-s390x.img UBUNTU_IMGS = $(UBUNTU_OLD_IMGS) $(UBUNTU_NEW_IMGS) -DOWNLOAD_ASSETS = mbuto podman \ +DOWNLOAD_ASSETS = exeter mbuto podman \ $(DEBIAN_IMGS) $(FEDORA_IMGS) $(OPENSUSE_IMGS) $(UBUNTU_IMGS) TESTDATA_ASSETS = small.bin big.bin medium.bin LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ @@ -63,7 +63,10 @@ LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ ASSETS = $(DOWNLOAD_ASSETS) $(LOCAL_ASSETS) -AVOCADO_JOBS = avocado/static_checkers.json +EXETER_SH = build/static_checkers.sh +EXETER_JOBS = $(EXETER_SH:%.sh=%.json) + +AVOCADO_JOBS = $(EXETER_JOBS) avocado/static_checkers.json PYTHON = python3 VENV = venv @@ -78,6 +81,9 @@ assets: $(ASSETS) pull-%: % git -C $* pull +exeter: + git clone https://gitlab.com/dgibson/exeter.git + mbuto: git clone git://mbuto.sh/mbuto @@ -128,8 +134,11 @@ venv: $(PYTHON) -m venv $(VENV) $(PIP) install avocado-framework +%.json: %.sh pull-exeter + sh $< --avocado > $@ + .PHONY: avocado -avocado: venv +avocado: venv $(AVOCADO_JOBS) $(RUN_AVOCADO) all $(AVOCADO_JOBS) check: assets @@ -144,6 +153,7 @@ clean: rm -rf test_logs rm -f prepared-*.qcow2 prepared-*.img rm -rf $(VENV) + rm -f $(EXETER_JOBS) realclean: clean rm -rf $(DOWNLOAD_ASSETS) diff --git a/test/build/.gitignore b/test/build/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/test/build/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/test/build/static_checkers.sh b/test/build/static_checkers.sh new file mode 100644 index 00000000..41152c25 --- /dev/null +++ b/test/build/static_checkers.sh @@ -0,0 +1,30 @@ +#! /bin/sh +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# PASST - Plug A Simple Socket Transport +# for qemu/UNIX domain socket mode +# +# PASTA - Pack A Subtle Tap Abstraction +# for network namespace/tap device mode +# +# test/build/static_checkers.sh - Run static checkers +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +source $(dirname $0)/../exeter/sh/exeter.sh + +cppcheck () { + make -C .. cppcheck +} +exeter_register cppcheck + +clang_tidy () { + make -C .. clang-tidy +} +exeter_register clang_tidy + +exeter_main "$@" + + -- 2.46.0
Create a Python package "tasst" with common helper code for use in passt and pasta tests. Initially it just has a placeholder selftest. Add a "make meta" target to run selftests for the test infrastructure itself. For now this runs the dummy selftest, the flake8 Python linter and the mypy static type checker. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/Makefile | 30 ++++++++++++++++++++++++++++-- test/build/static_checkers.sh | 2 -- test/meta/.gitignore | 1 + test/meta/lint.sh | 28 ++++++++++++++++++++++++++++ test/tasst/.gitignore | 1 + test/tasst/__init__.py | 11 +++++++++++ test/tasst/__main__.py | 22 ++++++++++++++++++++++ 7 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 test/meta/.gitignore create mode 100644 test/meta/lint.sh create mode 100644 test/tasst/.gitignore create mode 100644 test/tasst/__init__.py create mode 100644 test/tasst/__main__.py diff --git a/test/Makefile b/test/Makefile index d5a0f776..1daf1999 100644 --- a/test/Makefile +++ b/test/Makefile @@ -6,6 +6,8 @@ # Author: David Gibson <david(a)gibson.dropbear.id.au> WGET = wget -c +FLAKE8 = flake8-3 +MYPY = mypy DEBIAN_IMGS = debian-8.11.0-openstack-amd64.qcow2 \ debian-9-nocloud-amd64-daily-20200210-166.qcow2 \ @@ -68,10 +70,18 @@ EXETER_JOBS = $(EXETER_SH:%.sh=%.json) AVOCADO_JOBS = $(EXETER_JOBS) avocado/static_checkers.json +TASST_SRCS = __init__.py __main__.py + +EXETER_META = meta/lint.json meta/tasst.json +META_JOBS = $(EXETER_META) + +PYPKGS = tasst + PYTHON = python3 VENV = venv PIP = $(VENV)/bin/pip3 RUN_AVOCADO = $(VENV)/bin/python3 run_avocado +PYTHONPATH = exeter/py3 CFLAGS = -Wall -Werror -Wextra -pedantic -std=c99 @@ -137,10 +147,26 @@ venv: %.json: %.sh pull-exeter sh $< --avocado > $@ +%.json: %.py pull-exeter + PYTHONPATH=$(PYTHONPATH) $(PYTHON) $< --avocado > $@ + +meta/tasst.json: $(TASST_SRCS:%=tasst/%) $(VENV) pull-exeter + PYTHONPATH=$(PYTHONPATH) $(PYTHON) -m tasst --avocado > $@ + .PHONY: avocado avocado: venv $(AVOCADO_JOBS) $(RUN_AVOCADO) all $(AVOCADO_JOBS) +.PHONY: meta +meta: venv $(META_JOBS) + PYTHONPATH=$(PYTHONPATH) $(RUN_AVOCADO) meta $(META_JOBS) + +flake8: + $(FLAKE8) $(PYPKGS) + +mypy: + PYTHONPATH=$(PYTHONPATH) $(MYPY) --no-namespace-packages --strict $(PYPKGS) + check: assets ./run @@ -152,8 +178,8 @@ clean: rm -f $(LOCAL_ASSETS) rm -rf test_logs rm -f prepared-*.qcow2 prepared-*.img - rm -rf $(VENV) - rm -f $(EXETER_JOBS) + rm -rf $(VENV) tasst/__pycache__ + rm -f $(EXETER_JOBS) $(EXETER_META) realclean: clean rm -rf $(DOWNLOAD_ASSETS) diff --git a/test/build/static_checkers.sh b/test/build/static_checkers.sh index 41152c25..e02503d4 100644 --- a/test/build/static_checkers.sh +++ b/test/build/static_checkers.sh @@ -26,5 +26,3 @@ clang_tidy () { exeter_register clang_tidy exeter_main "$@" - - diff --git a/test/meta/.gitignore b/test/meta/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/test/meta/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/test/meta/lint.sh b/test/meta/lint.sh new file mode 100644 index 00000000..661c2267 --- /dev/null +++ b/test/meta/lint.sh @@ -0,0 +1,28 @@ +#! /bin/sh +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# PASST - Plug A Simple Socket Transport +# for qemu/UNIX domain socket mode +# +# PASTA - Pack A Subtle Tap Abstraction +# for network namespace/tap device mode +# +# test/meta/lint.sh - Linters for the test code +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +source $(dirname $0)/../exeter/sh/exeter.sh + +flake8 () { + make flake8 +} +exeter_register flake8 + +mypy () { + make mypy +} +exeter_register mypy + +exeter_main "$@" diff --git a/test/tasst/.gitignore b/test/tasst/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/test/tasst/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/test/tasst/__init__.py b/test/tasst/__init__.py new file mode 100644 index 00000000..c1d5d9dd --- /dev/null +++ b/test/tasst/__init__.py @@ -0,0 +1,11 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport +library of test helpers for passt & pasta +""" diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py new file mode 100644 index 00000000..310e31d7 --- /dev/null +++ b/test/tasst/__main__.py @@ -0,0 +1,22 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport +library of test helpers for passt & pasta +""" + +import exeter + + +(a)exeter.test +def placeholder() -> None: + pass + + +if __name__ == '__main__': + exeter.main() -- 2.46.0
Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/Makefile | 3 +- test/tasst/__main__.py | 19 +++- test/tasst/cmdsite.py | 193 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 test/tasst/cmdsite.py diff --git a/test/Makefile b/test/Makefile index 1daf1999..f1632f4d 100644 --- a/test/Makefile +++ b/test/Makefile @@ -70,7 +70,8 @@ EXETER_JOBS = $(EXETER_SH:%.sh=%.json) AVOCADO_JOBS = $(EXETER_JOBS) avocado/static_checkers.json -TASST_SRCS = __init__.py __main__.py +TASST_MODS = $(shell python3 -m tasst --modules) +TASST_SRCS = __init__.py __main__.py $(TASST_MODS) EXETER_META = meta/lint.json meta/tasst.json META_JOBS = $(EXETER_META) diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 310e31d7..4ea4c593 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -10,13 +10,24 @@ Test A Simple Socket Transport library of test helpers for passt & pasta """ -import exeter +import importlib +import sys +import exeter -(a)exeter.test -def placeholder() -> None: - pass +MODULES = [ + 'cmdsite', +] if __name__ == '__main__': + if sys.argv[1:] == ["--modules"]: + for m in MODULES: + print(m.replace('.', '/') + '.py') + sys.exit(0) + + for m in MODULES: + mod = importlib.import_module('.' + m, __package__) + mod.selftests() + exeter.main() diff --git a/test/tasst/cmdsite.py b/test/tasst/cmdsite.py new file mode 100644 index 00000000..ea2bdaa3 --- /dev/null +++ b/test/tasst/cmdsite.py @@ -0,0 +1,193 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +tasst/snh.py - Simulated network hosts for testing +""" + +from __future__ import annotations + +import contextlib +import enum +import subprocess +import sys +from typing import Any, Iterator, Optional + +import exeter + + +class Capture(enum.Enum): + STDOUT = 1 + + +# We might need our own versions of these eventually, but for now we +# can just alias the ones in subprocess +CompletedCmd = subprocess.CompletedProcess[bytes] +TimeoutExpired = subprocess.TimeoutExpired +CmdError = subprocess.CalledProcessError + + +class RunningCmd: + """ + A background process running on a CmdSite + """ + site: CmdSite + cmd: tuple[str, ...] + check: bool + popen: subprocess.Popen[bytes] + + def __init__(self, site: CmdSite, popen: subprocess.Popen[bytes], + *cmd: str, check: bool = True) -> None: + self.site = site + self.popen = popen + self.cmd = cmd + self.check = check + + def run(self, **kwargs: Any) -> CompletedCmd: + stdout, stderr = self.popen.communicate(**kwargs) + cp = CompletedCmd(self.popen.args, self.popen.returncode, + stdout, stderr) + if self.check: + cp.check_returncode() + return cp + + def terminate(self) -> None: + self.popen.terminate() + + def kill(self) -> None: + self.popen.kill() + + +class CmdSite(exeter.Scenario): + """ + A (usually virtual or simulated) location where we can execute + commands and configure networks. + + """ + name: str + + def __init__(self, name: str) -> None: + self.name = name # For debugging + + def output(self, *cmd: str, **kwargs: Any) -> bytes: + proc = self.fg(*cmd, capture=Capture.STDOUT, **kwargs) + return proc.stdout + + def fg(self, *cmd: str, timeout: Optional[float] = None, **kwargs: Any) \ + -> CompletedCmd: + # We don't use subprocess.run() because it kills without + # attempting to terminate on timeout + with self.bg(*cmd, **kwargs) as proc: + res = proc.run(timeout=timeout) + return res + + def sh(self, script: str, **kwargs: Any) -> None: + for cmd in script.splitlines(): + self.fg(cmd, shell=True, **kwargs) + + @contextlib.contextmanager + def bg(self, *cmd: str, capture: Optional[Capture] = None, + check: bool = True, context_timeout: float = 1.0, **kwargs: Any) \ + -> Iterator[RunningCmd]: + if capture == Capture.STDOUT: + kwargs['stdout'] = subprocess.PIPE + print(f"Site {self.name}: {cmd}", file=sys.stderr) + with self.popen(*cmd, **kwargs) as popen: + proc = RunningCmd(self, popen, *cmd, check=check) + try: + yield proc + finally: + try: + popen.wait(timeout=context_timeout) + except subprocess.TimeoutExpired as e: + popen.terminate() + try: + popen.wait(timeout=context_timeout) + except subprocess.TimeoutExpired: + popen.kill() + raise e + + def popen(self, *cmd: str, **kwargs: Any) -> subprocess.Popen[bytes]: + raise NotImplementedError + + @exeter.scenariotest + def test_true(self) -> None: + self.fg('true') + + @exeter.scenariotest + def test_false(self) -> None: + exeter.assert_raises(CmdError, self.fg, 'false') + + @exeter.scenariotest + def test_echo(self) -> None: + msg = 'Hello tasst' + out = self.output('echo', f'{msg}') + exeter.assert_eq(out, msg.encode('utf-8') + b'\n') + + @exeter.scenariotest + def test_timeout(self) -> None: + exeter.assert_raises(TimeoutExpired, self.fg, + 'sleep', 'infinity', timeout=0.1, check=False) + + @exeter.scenariotest + def test_bg_true(self) -> None: + with self.bg('true') as proc: + proc.run() + + @exeter.scenariotest + def test_bg_false(self) -> None: + with self.bg('false') as proc: + exeter.assert_raises(CmdError, proc.run) + + @exeter.scenariotest + def test_bg_echo(self) -> None: + msg = 'Hello tasst' + with self.bg('echo', f'{msg}', capture=Capture.STDOUT) as proc: + res = proc.run() + exeter.assert_eq(res.stdout, msg.encode('utf-8') + b'\n') + + @exeter.scenariotest + def test_bg_timeout(self) -> None: + with self.bg('sleep', 'infinity') as proc: + exeter.assert_raises(TimeoutExpired, proc.run, timeout=0.1) + proc.terminate() + + @exeter.scenariotest + def test_bg_context_timeout(self) -> None: + def run_timeout() -> None: + with self.bg('sleep', 'infinity', context_timeout=0.1): + pass + exeter.assert_raises(TimeoutExpired, run_timeout) + + +class BuildHost(CmdSite): + """ + Represents the host on which the tests are running (as opposed + to some simulated host created by the tests) + """ + + def __init__(self) -> None: + super().__init__('BUILD_HOST') + + def popen(self, *cmd: str, privilege: bool = False, **kwargs: Any) \ + -> subprocess.Popen[bytes]: + assert not privilege, \ + "BUG: Shouldn't run commands with privilege on host" + return subprocess.Popen(cmd, **kwargs) + + +BUILD_HOST = BuildHost() + + +def build_host() -> Iterator[BuildHost]: + yield BUILD_HOST + + +def selftests() -> None: + CmdSite.test(build_host) -- 2.46.0
Add a new test script to run the equivalent of the tests in build/all using exeter and Avocado. This new version of the tests is more robust than the original, since it makes a temporary copy of the source tree so will not be affected by concurrent manual builds. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/Makefile | 15 +++++--- test/build/.gitignore | 1 + test/build/build.py | 86 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 test/build/build.py diff --git a/test/Makefile b/test/Makefile index f1632f4d..5cd5c781 100644 --- a/test/Makefile +++ b/test/Makefile @@ -64,9 +64,12 @@ LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ $(TESTDATA_ASSETS) ASSETS = $(DOWNLOAD_ASSETS) $(LOCAL_ASSETS) +AVOCADO_ASSETS = +META_ASSETS = nstool EXETER_SH = build/static_checkers.sh -EXETER_JOBS = $(EXETER_SH:%.sh=%.json) +EXETER_PY = build/build.py +EXETER_JOBS = $(EXETER_SH:%.sh=%.json) $(EXETER_PY:%.py=%.json) AVOCADO_JOBS = $(EXETER_JOBS) avocado/static_checkers.json @@ -76,13 +79,13 @@ TASST_SRCS = __init__.py __main__.py $(TASST_MODS) EXETER_META = meta/lint.json meta/tasst.json META_JOBS = $(EXETER_META) -PYPKGS = tasst +PYPKGS = tasst $(EXETER_PY) PYTHON = python3 VENV = venv PIP = $(VENV)/bin/pip3 RUN_AVOCADO = $(VENV)/bin/python3 run_avocado -PYTHONPATH = exeter/py3 +PYTHONPATH = exeter/py3:. CFLAGS = -Wall -Werror -Wextra -pedantic -std=c99 @@ -155,11 +158,11 @@ meta/tasst.json: $(TASST_SRCS:%=tasst/%) $(VENV) pull-exeter PYTHONPATH=$(PYTHONPATH) $(PYTHON) -m tasst --avocado > $@ .PHONY: avocado -avocado: venv $(AVOCADO_JOBS) - $(RUN_AVOCADO) all $(AVOCADO_JOBS) +avocado: venv $(AVOCADO_ASSETS) $(AVOCADO_JOBS) + PYTHONPATH=$(PYTHONPATH) $(RUN_AVOCADO) all $(AVOCADO_JOBS) .PHONY: meta -meta: venv $(META_JOBS) +meta: venv $(META_ASSETS) $(META_JOBS) PYTHONPATH=$(PYTHONPATH) $(RUN_AVOCADO) meta $(META_JOBS) flake8: diff --git a/test/build/.gitignore b/test/build/.gitignore index a6c57f5f..4ef40dd0 100644 --- a/test/build/.gitignore +++ b/test/build/.gitignore @@ -1 +1,2 @@ *.json +build.exeter diff --git a/test/build/build.py b/test/build/build.py new file mode 100644 index 00000000..cc4c0819 --- /dev/null +++ b/test/build/build.py @@ -0,0 +1,86 @@ +#! /usr/bin/env python3 +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# PASST - Plug A Simple Socket Transport +# for qemu/UNIX domain socket mode +# +# PASTA - Pack A Subtle Tap Abstraction +# for network namespace/tap device mode +# +# test/build/build.sh - Test build and install targets +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +import contextlib +import os +from pathlib import Path +import tempfile +from typing import Iterable, Iterator + +import exeter + +import tasst.cmdsite + + +# For convenience +sh = tasst.cmdsite.BUILD_HOST.sh + + +(a)contextlib.contextmanager +def clone_sources() -> Iterator[str]: + os.chdir('..') # Move from test/ to repo base + with tempfile.TemporaryDirectory(ignore_cleanup_errors=False) as tmpdir: + sh(f"cp --parents $(git ls-files) {tmpdir}") + os.chdir(tmpdir) + yield tmpdir + + +def test_make(target: str, outputs: Iterable[str]) -> None: + outpaths = [Path(o) for o in outputs] + with clone_sources(): + for o in outpaths: + assert not o.exists(), f"{o} existed before make" + sh(f'make {target} CFLAGS="-Werror"') + for o in outpaths: + assert o.exists(), f"{o} wasn't made" + sh('make clean') + for o in outpaths: + assert not o.exists(), f"{o} existed after make clean" + + +exeter.register('make_passt', test_make, 'passt', ['passt']) +exeter.register('make_pasta', test_make, 'pasta', ['pasta']) +exeter.register('make_qrap', test_make, 'qrap', ['qrap']) +exeter.register('make_all', test_make, 'all', ['passt', 'pasta', 'qrap']) + + +(a)exeter.test +def test_install_uninstall() -> None: + with clone_sources(): + with tempfile.TemporaryDirectory(ignore_cleanup_errors=False) \ + as prefix: + bindir = Path(prefix) / 'bin' + mandir = Path(prefix) / 'share/man' + progs = ['passt', 'pasta', 'qrap'] + + # Install + sh(f'make install CFLAGS="-Werror" prefix={prefix}') + + for prog in progs: + exe = bindir / prog + assert exe.is_file(), f"{exe} does not exist as a regular file" + sh(f'man -M {mandir} -W {prog}') + + # Uninstall + sh(f'make uninstall prefix={prefix}') + + for prog in progs: + exe = bindir / prog + assert not exe.exists(), f"{exe} exists after uninstall" + sh(f'! man -M {mandir} -W {prog}') + + +if __name__ == '__main__': + exeter.main() -- 2.46.0
Use our existing nstool C helper, add python wrappers to easily run commands in various namespaces. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/unshare.py | 166 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 test/tasst/unshare.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 4ea4c593..9cba8985 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -17,6 +17,7 @@ import exeter MODULES = [ 'cmdsite', + 'unshare', ] diff --git a/test/tasst/unshare.py b/test/tasst/unshare.py new file mode 100644 index 00000000..15b760b5 --- /dev/null +++ b/test/tasst/unshare.py @@ -0,0 +1,166 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +unshare.py - Create and run commands in Linux namespaces +""" + +import contextlib +import os +import subprocess +import tempfile +from typing import Any, Callable, Iterator + +import exeter + +from . import cmdsite + + +# FIXME: Can this be made more portable? +UNIX_PATH_MAX = 108 + +NSTOOL_BIN = './nstool' + + +class Unshare(cmdsite.CmdSite): + """A bundle of Linux namespaces managed by nstool""" + + sockpath: str + parent: cmdsite.CmdSite + _pid: int + + def __init__(self, name: str, sockpath: str, + parent: cmdsite.CmdSite = cmdsite.BUILD_HOST, + parent_priv: bool = False) -> None: + if len(sockpath) > UNIX_PATH_MAX: + raise ValueError( + f'Unix domain socket path "{sockpath}" is too long' + ) + + super().__init__(name) + self.sockpath = sockpath + self.parent = parent + self.parent_priv = parent_priv + self.parent.fg(NSTOOL_BIN, 'info', '-wp', self.sockpath, timeout=1) + + # PID of the nstool hold process as seen by another site which can + # see the nstool socket (important when using PID namespaces) + def relative_pid(self, relative_to: cmdsite.CmdSite) -> int | None: + cmd = [NSTOOL_BIN, 'info', '-p', self.sockpath] + relpid = int(relative_to.output(*cmd)) + if not relpid: + return None + return relpid + + def popen(self, *cmd: str, privilege: bool = False, + **kwargs: Any) -> subprocess.Popen[bytes]: + hostcmd = [NSTOOL_BIN, 'exec'] + if privilege: + hostcmd.append('--keep-caps') + hostcmd += [self.sockpath, '--'] + hostcmd += list(cmd) + return self.parent.popen(*hostcmd, privilege=self.parent_priv, + **kwargs) + + +(a)contextlib.contextmanager +def unshare(name: str, *opts: str, + parent: cmdsite.CmdSite = cmdsite.BUILD_HOST, + privilege: bool = False) -> Iterator[Unshare]: + # Create path for temporary nstool Unix socket + with tempfile.TemporaryDirectory() as tmpd: + sockpath = os.path.join(tmpd, name) + cmd = ['unshare'] + list(opts) + cmd += ['--', NSTOOL_BIN, 'hold', sockpath] + with parent.bg(*cmd, privilege=privilege) as holder: + try: + yield Unshare(name, sockpath, parent=parent, + parent_priv=privilege) + finally: + try: + parent.fg(NSTOOL_BIN, 'stop', sockpath) + finally: + try: + holder.run(timeout=0.1) + holder.kill() + finally: + try: + os.remove(sockpath) + except FileNotFoundError: + pass + + +def _userns_setup() -> Iterator[cmdsite.CmdSite]: + with unshare('usernetns', '-Ucn') as site: + yield site + + +def _nested_setup() -> Iterator[cmdsite.CmdSite]: + with unshare('userns', '-Uc') as userns: + with unshare('netns', '-n', parent=userns, privilege=True) as netns: + yield netns + + +def _pidns_setup() -> Iterator[cmdsite.CmdSite]: + with unshare('pidns', '-Upfn') as site: + yield site + + +def connect_site() -> Iterator[Unshare]: + with tempfile.TemporaryDirectory() as tmpd: + sockpath = os.path.join(tmpd, 'nons') + holdcmd = [NSTOOL_BIN, 'hold', sockpath] + with subprocess.Popen(holdcmd) as holder: + try: + yield Unshare("fakens", sockpath) + finally: + holder.kill() + os.remove(sockpath) + + +def selftests() -> None: + @exeter.test + def test_userns() -> None: + cmd = ['capsh', '--has-p=CAP_SETUID'] + status = cmdsite.BUILD_HOST.fg(*cmd, check=False) + assert status.returncode != 0 + with unshare('userns', '-Ucn') as ns: + ns.fg(*cmd, privilege=True) + + @exeter.test + def test_relative_pid() -> None: + with unshare('pidns', '-Upfn') as site: + # The holder is init (pid 1) within its own pidns + exeter.assert_eq(site.relative_pid(site), 1) + + def sockdir_cleanup(setup: Callable[[], Iterator[cmdsite.CmdSite]]) \ + -> None: + cm = contextlib.contextmanager(setup) + + def mess(sockpaths: list[str]) -> None: + with cm() as site: + while isinstance(site, Unshare): + sockpaths.append(site.sockpath) + site = site.parent + + sockpaths: list[str] = [] + mess(sockpaths) + assert sockpaths + for path in sockpaths: + assert not os.path.exists(os.path.dirname(path)) + + # General tests for all the nstool examples + for setup in [_userns_setup, _nested_setup, _pidns_setup]: + # Common cmdsite.CmdSite & NetSite tests + cmdsite.CmdSite.test(setup) + + exeter.register(f'{setup.__qualname__}|sockdir_cleanup', + sockdir_cleanup, setup) + + cmdsite.CmdSite.test(connect_site) -- 2.46.0
Specifically these operate to configure IP using ip(8) running within any CmdSite. We also include things to allocate addresses in example networks. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/ip.py | 234 ++++++++++++++++++++++++++++++++ test/tasst/selftest/__init__.py | 16 +++ 3 files changed, 251 insertions(+) create mode 100644 test/tasst/ip.py create mode 100644 test/tasst/selftest/__init__.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 9cba8985..e7456e8b 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -17,6 +17,7 @@ import exeter MODULES = [ 'cmdsite', + 'ip', 'unshare', ] diff --git a/test/tasst/ip.py b/test/tasst/ip.py new file mode 100644 index 00000000..7d9b1c11 --- /dev/null +++ b/test/tasst/ip.py @@ -0,0 +1,234 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +tasst/ip.py - Configure and read IP on simulated sites +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import ipaddress +import json +from typing import Any, Iterator, Literal, Sequence, cast + +import exeter + +from . import cmdsite, unshare + +Addr = ipaddress.IPv4Address | ipaddress.IPv6Address +AddrMask = ipaddress.IPv4Interface | ipaddress.IPv6Interface +Net = ipaddress.IPv4Network | ipaddress.IPv6Network + + +# Loopback addresses, for convenience +LOOPBACK4 = ipaddress.ip_address('127.0.0.1') +LOOPBACK6 = ipaddress.ip_address('::1') + +# Documentation test networks defined by RFC 5737 +TEST_NET_1 = ipaddress.ip_network('192.0.2.0/24') +TEST_NET_2 = ipaddress.ip_network('198.51.100.0/24') +TEST_NET_3 = ipaddress.ip_network('203.0.113.0/24') + +# Documentation test network defined by RFC 3849 +TEST_NET6 = ipaddress.ip_network('2001:db8::/32') +# Some subnets of that for our usage +TEST_NET6_TASST_A = ipaddress.ip_network('2001:db8:9a55:aaaa::/64') +TEST_NET6_TASST_B = ipaddress.ip_network('2001:db8:9a55:bbbb::/64') +TEST_NET6_TASST_C = ipaddress.ip_network('2001:db8:9a55:cccc::/64') + + +class IpiAllocator: + """IP address allocator""" + + DEFAULT_NETS = (TEST_NET_1, TEST_NET6_TASST_A) + + def __init__(self, *nets: Net | str) -> None: + if not nets: + nets = self.DEFAULT_NETS + + self.nets = [ipaddress.ip_network(n) for n in nets] + self.hostses = [n.hosts() for n in self.nets] + + def next_ipis(self) \ + -> list[ipaddress.IPv4Interface | ipaddress.IPv6Interface]: + addrs = [next(h) for h in self.hostses] + return [ipaddress.ip_interface(f'{a}/{n.prefixlen}') + for a, n in zip(addrs, self.nets)] + + +def ifs(site: cmdsite.CmdSite) -> Sequence[str]: + info = json.loads(site.output('ip', '-j', 'link', 'show')) + return [i['ifname'] for i in info] + + +def ifup(site: cmdsite.CmdSite, ifname: str, *addrs: AddrMask, + dad: Literal['disable', 'optimistic', None] = None) -> None: + if dad == 'disable': + site.fg('sysctl', f'net.ipv6.conf.{ifname}.accept_dad=0', + privilege=True) + elif dad == 'optimistic': + site.fg('sysctl', f'net.ipv6.conf.{ifname}.optimistic_dad=1', + privilege=True) + elif dad is not None: + raise ValueError + + for a in addrs: + site.fg('ip', 'addr', 'add', f'{a.with_prefixlen}', + 'dev', ifname, privilege=True) + + site.fg('ip', 'link', 'set', ifname, 'up', privilege=True) + + +def addrs(site: cmdsite.CmdSite, ifname: str, **criteria: str) \ + -> Sequence[AddrMask]: + info = json.loads(site.output('ip', '-j', 'addr', 'show', f'{ifname}')) + assert len(info) == 1 # We specified a specific interface + + ais = [ai for ai in info[0]['addr_info']] + for key, value in criteria.items(): + ais = [ai for ai in ais if key in ai and ai[key] == value] + + # Return just the parsed, non-tentative addresses + return [ipaddress.ip_interface(f'{ai["local"]}/{ai["prefixlen"]}') + for ai in ais if 'tentative' not in ai] + + +def addr_wait(site: cmdsite.CmdSite, ifname: str, **criteria: str) \ + -> Sequence[AddrMask]: + while True: + a = addrs(site, ifname, **criteria) + if a: + return a + + +def mtu(site: cmdsite.CmdSite, ifname: str) -> int: + cmd = ['ip', '-j', 'link', 'show', ifname] + (info,) = json.loads(site.output(*cmd)) + return cast(int, info['mtu']) + + +def _routes(site: cmdsite.CmdSite, ipv: str, **criteria: str) -> Any: + routes = json.loads(site.output('ip', '-j', f'-{ipv}', 'route')) + for key, value in criteria.items(): + routes = [r for r in routes if key in r and r[key] == value] + + return routes + + +def routes4(site: cmdsite.CmdSite, **criteria: str) -> Any: + return _routes(site, '4', **criteria) + + +def routes6(site: cmdsite.CmdSite, **criteria: str) -> Any: + return _routes(site, '6', **criteria) + + +(a)dataclasses.dataclass +class BaseNetScenario(exeter.Scenario): + """Test that a site has sane looking basic networking""" + site: cmdsite.CmdSite + + @exeter.scenariotest + def has_lo(self) -> None: + assert 'lo' in ifs(self.site) + + @exeter.scenariotest + def lo_addrs(self) -> None: + expected = set(ipaddress.ip_interface(a) + for a in ['127.0.0.1/8', '::1/128']) + assert set(addrs(self.site, 'lo')) == expected + + @exeter.scenariotest + def lo_mtu(self) -> None: + exeter.assert_eq(mtu(self.site, 'lo'), 65536) + + +(a)dataclasses.dataclass +class IsolatedNetScenario(BaseNetScenario): + @exeter.scenariotest + def is_isolated(self) -> None: + exeter.assert_eq(list(ifs(self.site)), ['lo']) + + +def selftests() -> None: + @BaseNetScenario.test + def host() -> Iterator[BaseNetScenario]: + yield BaseNetScenario(cmdsite.BUILD_HOST) + + @IsolatedNetScenario.test + def netns() -> Iterator[IsolatedNetScenario]: + with unshare.unshare("netns", "-Ucn") as ns: + ifup(ns, 'lo') + yield IsolatedNetScenario(ns) + + ifname = 'dummy0' + dummy_ips = {ipaddress.ip_interface(a) for a in + ['192.0.2.1/24', '2001:db8:9a55::1/112', '10.1.2.3/8']} + dummy_routes4 = {i.network for i in dummy_ips + if isinstance(i, ipaddress.IPv4Interface)} + dummy_routes6 = {i.network for i in dummy_ips + if isinstance(i, ipaddress.IPv6Interface)} + dummy_routes6.add(ipaddress.IPv6Network('fe80::/64')) + + @contextlib.contextmanager + def dummy_setup() -> Iterator[cmdsite.CmdSite]: + with unshare.unshare('dummy', '-Un') as site: + site.fg('ip', 'link', 'add', 'type', 'dummy', privilege=True) + ifup(site, 'lo') + ifup(site, ifname, *dummy_ips, dad='disable') + yield site + + @exeter.test + def test_addr() -> None: + with dummy_setup() as site: + actual = set(addrs(site, ifname, scope='global')) + exeter.assert_eq(actual, dummy_ips) + + @exeter.test + def test_routes4() -> None: + with dummy_setup() as site: + actual = set(ipaddress.ip_interface(r['dst']).network + for r in routes4(site, dev=ifname)) + exeter.assert_eq(actual, dummy_routes4) + + @exeter.test + def test_routes6() -> None: + with dummy_setup() as site: + actual = set(ipaddress.ip_interface(r['dst']).network + for r in routes6(site, dev=ifname)) + exeter.assert_eq(actual, dummy_routes6) + + def ipa_test(nets: tuple[Net | str, ...], count: int = 12) -> None: + ipa = IpiAllocator(*nets) + + addrsets: list[set[ipaddress.IPv4Address | ipaddress.IPv6Address]] \ + = [set() for i in range(len(nets))] + for i in range(count): + addrs = ipa.next_ipis() + # Check we got as many addresses as expected + exeter.assert_eq(len(addrs), len(nets)) + for s, a, n in zip(addrsets, addrs, nets): + # Check the addresses belong to the right network + exeter.assert_eq(a.network, ipaddress.ip_network(n)) + s.add(a) + + # Check the addresses are unique + for s in addrsets: + exeter.assert_eq(len(s), count) + + @exeter.test + def ipa_test_default() -> None: + ipa_test(nets=IpiAllocator.DEFAULT_NETS) + + @exeter.test + def ipa_test_custom() -> None: + ipa_test(nets=('10.55.0.0/16', '192.168.55.0/24', + 'fd00:9a57:a000::/48')) diff --git a/test/tasst/selftest/__init__.py b/test/tasst/selftest/__init__.py new file mode 100644 index 00000000..d7742930 --- /dev/null +++ b/test/tasst/selftest/__init__.py @@ -0,0 +1,16 @@ +#! /usr/bin/python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +"""Test A Simple Socket Transport + +selftest/ - Selftests for the tasst library + +Usually, tests for the tasst helper library itself should go next to +the implementation of the thing being tested. Sometimes that's +inconvenient or impossible (usually because it would cause a circular +module dependency). In that case those tests can go here. +""" -- 2.46.0
Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/veth.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 test/tasst/veth.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index e7456e8b..4eab9157 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -19,6 +19,7 @@ MODULES = [ 'cmdsite', 'ip', 'unshare', + 'veth', ] diff --git a/test/tasst/veth.py b/test/tasst/veth.py new file mode 100644 index 00000000..7fa5cbb5 --- /dev/null +++ b/test/tasst/veth.py @@ -0,0 +1,80 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +selftest/veth.py - Test various veth configurations +""" + +import contextlib +from typing import Iterator, Literal +import ipaddress + +import exeter + +from . import cmdsite, ip, unshare + + +(a)contextlib.contextmanager +def veth(site: cmdsite.CmdSite, ifname: str, + peername: str, peer: unshare.Unshare | None = None) -> Iterator[None]: + site.fg('ip', 'link', 'add', ifname, 'type', 'veth', + 'peer', 'name', peername, privilege=True) + if peer is not None: + site.fg('ip', 'link', 'set', peername, + 'netns', f'{peer.relative_pid(site)}', privilege=True) + yield + site.fg('ip', 'link', 'del', ifname, privilege=True) + + +def selftests() -> None: + @contextlib.contextmanager + def veth_setup() -> Iterator[tuple[cmdsite.CmdSite, cmdsite.CmdSite]]: + with unshare.unshare('ns1', '-Un') as ns1: + with unshare.unshare('ns2', '-n', parent=ns1, + privilege=True) as ns2: + with veth(ns1, 'vetha', 'vethb', ns2): + yield (ns1, ns2) + + @exeter.test + def test_ifs() -> None: + with veth_setup() as (ns1, ns2): + exeter.assert_eq(set(ip.ifs(ns1)), set(['lo', 'vetha'])) + exeter.assert_eq(set(ip.ifs(ns2)), set(['lo', 'vethb'])) + + @exeter.test + def test_mtu() -> None: + with veth_setup() as (ns1, ns2): + exeter.assert_eq(ip.mtu(ns1, 'vetha'), 1500) + exeter.assert_eq(ip.mtu(ns2, 'vethb'), 1500) + + def test_slaac(dad: Literal['disable', 'optimistic', None]) -> None: + TESTMAC = '02:aa:bb:cc:dd:ee' + TESTIP = ipaddress.ip_interface('fe80::aa:bbff:fecc:ddee/64') + + with veth_setup() as (ns1, ns2): + ns1.fg('ip', 'link', 'set', 'dev', 'vetha', 'address', TESTMAC, + privilege=True) + + ip.ifup(ns1, 'vetha', dad=dad) + ip.ifup(ns2, 'vethb') + + addrs = ip.addr_wait(ns1, 'vetha', family='inet6', scope='link') + exeter.assert_eq(addrs, [TESTIP]) + + @exeter.test + def test_dad() -> None: + test_slaac(dad=None) + + @exeter.test + def test_optimistic_dad() -> None: + test_slaac(dad='optimistic') + + @exeter.test + def test_no_dad() -> None: + test_slaac(dad='disable') -- 2.46.0
Many of our existing tests are based on using socat to transfer between various locations connected via pasta or passt. Add helpers to make avocado tests performing similar transfers. Add selftests to verify those work as expected when we don't have pasta or passt involved yet. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/Makefile | 2 +- test/tasst/__main__.py | 1 + test/tasst/transfer.py | 193 +++++++++++++++++++++++++++++++++++++++++ test/tasst/veth.py | 26 +++++- 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 test/tasst/transfer.py diff --git a/test/Makefile b/test/Makefile index 5cd5c781..3ac67b66 100644 --- a/test/Makefile +++ b/test/Makefile @@ -65,7 +65,7 @@ LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ ASSETS = $(DOWNLOAD_ASSETS) $(LOCAL_ASSETS) AVOCADO_ASSETS = -META_ASSETS = nstool +META_ASSETS = nstool small.bin medium.bin big.bin EXETER_SH = build/static_checkers.sh EXETER_PY = build/build.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 4eab9157..251edae5 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -18,6 +18,7 @@ import exeter MODULES = [ 'cmdsite', 'ip', + 'transfer', 'unshare', 'veth', ] diff --git a/test/tasst/transfer.py b/test/tasst/transfer.py new file mode 100644 index 00000000..6654b6da --- /dev/null +++ b/test/tasst/transfer.py @@ -0,0 +1,193 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +transfer.py - Helpers for testing data transfers +""" + +import dataclasses +import ipaddress +import time +from typing import Iterator, Optional + +import exeter + +from . import cmdsite, ip, unshare + +# HACK: how long to wait for the server to be ready and listening (s) +SERVER_READY_DELAY = 0.1 # 1/10th of a second + + +# socat needs IPv6 addresses in square brackets +def socat_ip(ip: ip.Addr) -> str: + if isinstance(ip, ipaddress.IPv6Address): + return f'[{ip}]' + elif isinstance(ip, ipaddress.IPv4Address): + return f'{ip}' + raise TypeError + + +def socat_upload(datafile: str, csite: cmdsite.CmdSite, + ssite: cmdsite.CmdSite, connect: str, listen: str) -> None: + srcdata = csite.output('cat', f'{datafile}') + with ssite.bg('socat', '-u', f'{listen}', 'STDOUT', + capture=cmdsite.Capture.STDOUT) as server: + time.sleep(SERVER_READY_DELAY) + + # Can't use csite.fg() here, because while we wait for the + # client to complete we won't be reading from the output pipe + # of the server, meaning it will freeze once the buffers fill + with csite.bg('socat', '-u', f'OPEN:{datafile}', f'{connect}') \ + as client: + res = server.run() + client.run() + exeter.assert_eq(srcdata, res.stdout) + + +def socat_download(datafile: str, csite: cmdsite.CmdSite, + ssite: cmdsite.CmdSite, + connect: str, listen: str) -> None: + srcdata = ssite.output('cat', f'{datafile}') + with ssite.bg('socat', '-u', f'OPEN:{datafile}', f'{listen}'): + time.sleep(SERVER_READY_DELAY) + dstdata = csite.output('socat', '-u', f'{connect}', 'STDOUT') + exeter.assert_eq(srcdata, dstdata) + + +def _tcp_socat(connectip: ip.Addr, connectport: int, + listenip: Optional[ip.Addr], listenport: Optional[int], + fromip: Optional[ip.Addr]) -> tuple[str, str]: + v6 = isinstance(connectip, ipaddress.IPv6Address) + if listenport is None: + listenport = connectport + if v6: + connect = f'TCP6:[{connectip}]:{connectport},ipv6only' + listen = f'TCP6-LISTEN:{listenport},ipv6only' + else: + connect = f'TCP4:{connectip}:{connectport}' + listen = f'TCP4-LISTEN:{listenport}' + if listenip is not None: + listen += f',bind={socat_ip(listenip)}' + if fromip is not None: + connect += f',bind={socat_ip(fromip)}' + return (connect, listen) + + +def tcp_upload(datafile: str, cs: cmdsite.CmdSite, ss: cmdsite.CmdSite, + connectip: ip.Addr, connectport: int, + listenip: Optional[ip.Addr] = None, + listenport: Optional[int] = None, + fromip: Optional[ip.Addr] = None) -> None: + connect, listen = _tcp_socat(connectip, connectport, listenip, listenport, + fromip) + socat_upload(datafile, cs, ss, connect, listen) + + +def tcp_download(datafile: str, cs: cmdsite.CmdSite, ss: cmdsite.CmdSite, + connectip: ip.Addr, connectport: int, + listenip: Optional[ip.Addr] = None, + listenport: Optional[int] = None, + fromip: Optional[ip.Addr] = None) -> None: + connect, listen = _tcp_socat(connectip, connectport, listenip, listenport, + fromip) + socat_download(datafile, cs, ss, connect, listen) + + +def udp_transfer(datafile: str, cs: cmdsite.CmdSite, ss: cmdsite.CmdSite, + connectip: ip.Addr, connectport: int, + listenip: Optional[ip.Addr] = None, + listenport: Optional[int] = None, + fromip: Optional[ip.Addr] = None) -> None: + v6 = isinstance(connectip, ipaddress.IPv6Address) + if listenport is None: + listenport = connectport + if v6: + connect = f'UDP6:[{connectip}]:{connectport},ipv6only,shut-null' + listen = f'UDP6-LISTEN:{listenport},ipv6only,null-eof' + else: + connect = f'UDP4:{connectip}:{connectport},shut-null' + listen = f'UDP4-LISTEN:{listenport},null-eof' + if listenip is not None: + listen += f',bind={socat_ip(listenip)}' + if fromip is not None: + connect += f',bind={socat_ip(fromip)}' + + socat_upload(datafile, cs, ss, connect, listen) + + +SMALL_DATA = 'small.bin' +BIG_DATA = 'big.bin' +UDP_DATA = 'medium.bin' + + +(a)dataclasses.dataclass +class TransferScenario(exeter.Scenario): + client: cmdsite.CmdSite + server: cmdsite.CmdSite + connect_ip: ip.Addr + connect_port: int + listen_ip: Optional[ip.Addr] = None + from_ip: Optional[ip.Addr] = None + listen_port: Optional[int] = None + + def tcp_upload(self, datafile: str) -> None: + tcp_upload(datafile, self.client, self.server, + self.connect_ip, self.connect_port, + listenip=self.listen_ip, listenport=self.listen_port, + fromip=self.from_ip) + + @exeter.scenariotest + def tcp_small_upload(self) -> None: + self.tcp_upload(SMALL_DATA) + + @exeter.scenariotest + def tcp_big_upload(self) -> None: + self.tcp_upload(BIG_DATA) + + def tcp_download(self, datafile: str) -> None: + tcp_download(datafile, self.client, self.server, + self.connect_ip, self.connect_port, + listenip=self.listen_ip, listenport=self.listen_port, + fromip=self.from_ip) + + @exeter.scenariotest + def tcp_small_download(self) -> None: + self.tcp_download(SMALL_DATA) + + @exeter.scenariotest + def tcp_big_download(self) -> None: + self.tcp_download(BIG_DATA) + + @exeter.scenariotest + def udp_transfer(self, datafile: str = UDP_DATA) -> None: + udp_transfer(datafile, self.client, self.server, + self.connect_ip, self.connect_port, + listenip=self.listen_ip, listenport=self.listen_port, + fromip=self.from_ip) + + +def local4() -> Iterator[TransferScenario]: + with unshare.unshare('ns', '-Un') as ns: + ip.ifup(ns, 'lo') + yield TransferScenario(client=ns, server=ns, + connect_ip=ip.LOOPBACK4, + connect_port=10000) + + +def local6() -> Iterator[TransferScenario]: + with unshare.unshare('ns', '-Un') as ns: + ip.ifup(ns, 'lo') + yield TransferScenario(client=ns, server=ns, + connect_ip=ip.LOOPBACK6, + connect_port=10000) + + +def selftests() -> None: + TransferScenario.test(local4) + TransferScenario.test(local6) diff --git a/test/tasst/veth.py b/test/tasst/veth.py index 7fa5cbb5..3a9c123b 100644 --- a/test/tasst/veth.py +++ b/test/tasst/veth.py @@ -17,7 +17,7 @@ import ipaddress import exeter -from . import cmdsite, ip, unshare +from . import cmdsite, ip, transfer, unshare @contextlib.contextmanager @@ -78,3 +78,27 @@ def selftests() -> None: @exeter.test def test_no_dad() -> None: test_slaac(dad='disable') + + def veth_transfer(ip1: ip.AddrMask, ip2: ip.AddrMask) \ + -> Iterator[transfer.TransferScenario]: + with veth_setup() as (ns1, ns2): + ip.ifup(ns1, 'lo') + ip.ifup(ns1, 'vetha', ip1, dad='disable') + ip.ifup(ns2, 'lo') + ip.ifup(ns2, 'vethb', ip2, dad='disable') + + yield transfer.TransferScenario(client=ns1, server=ns2, + connect_ip=ip2.ip, + connect_port=10000) + + ipa = ip.IpiAllocator() + ns1_ip4, ns1_ip6 = ipa.next_ipis() + ns2_ip4, ns2_ip6 = ipa.next_ipis() + + @transfer.TransferScenario.test + def veth_transfer4() -> Iterator[transfer.TransferScenario]: + yield from veth_transfer(ns1_ip4, ns2_ip4) + + @transfer.TransferScenario.test + def veth_transfer6() -> Iterator[transfer.TransferScenario]: + yield from veth_transfer(ns1_ip6, ns2_ip6) -- 2.46.0
Signed-iff-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/ndp.py | 106 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 test/tasst/ndp.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 251edae5..92319d46 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -18,6 +18,7 @@ import exeter MODULES = [ 'cmdsite', 'ip', + 'ndp', 'transfer', 'unshare', 'veth', diff --git a/test/tasst/ndp.py b/test/tasst/ndp.py new file mode 100644 index 00000000..0ea2f75e --- /dev/null +++ b/test/tasst/ndp.py @@ -0,0 +1,106 @@ +#! /usr/bin/env avocado-runner-avocado-classless + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +ndp.py - Helpers for testing NDP +""" + +import dataclasses +import ipaddress +import os +import tempfile +from typing import Iterator + +import exeter + +from . import cmdsite, ip, unshare, veth + + +(a)dataclasses.dataclass +class NdpScenario(exeter.Scenario): + client: cmdsite.CmdSite + ifname: str + network: ip.Net + gateway: ip.Addr + + @exeter.scenariotest + def ndp_addr(self) -> None: + # Wait for NDP to do its thing + (addr,) = ip.addr_wait(self.client, self.ifname, + family='inet6', scope='global') + + # The SLAAC address is derived from the guest ns MAC, so + # probably won't exactly match the host address (we need + # DHCPv6 for that). It should be in the right network though. + exeter.assert_eq(addr.network, self.network) + + @exeter.scenariotest + def ndp_route(self) -> None: + defroutes = ip.routes6(self.client, dst='default') + while not defroutes: + defroutes = ip.routes6(self.client, dst='default') + + exeter.assert_eq(len(defroutes), 1) + gw = ipaddress.ip_address(defroutes[0]['gateway']) + exeter.assert_eq(gw, self.gateway) + + +IFNAME = 'clientif' +NETWORK = ip.TEST_NET6_TASST_A +ipa = ip.IpiAllocator(NETWORK) +(ROUTER_IP6,) = ipa.next_ipis() + + +def setup_radvd() -> Iterator[NdpScenario]: + router_ifname = 'routerif' + + with unshare.unshare('client', '-Un') as client, \ + unshare.unshare('router', '-n', + parent=client, privilege=True) as router, \ + tempfile.TemporaryDirectory() as tmpdir, \ + veth.veth(client, IFNAME, router_ifname, router): + + # Configure the simulated router + confpath = os.path.join(tmpdir, 'radvd.conf') + pidfile = os.path.join(tmpdir, 'radvd.pid') + open(confpath, 'w', encoding='UTF-8').write( + f''' + interface {router_ifname} {{ + AdvSendAdvert on; + prefix {NETWORK} {{ + }}; + }}; + ''' + ) + + ip.ifup(router, 'lo') + ip.ifup(router, 'routerif', ROUTER_IP6) + + # Configure the client + ip.ifup(client, 'lo') + ip.ifup(client, IFNAME) + + # Get the router's link-local-address + (router_ll,) = ip.addr_wait(router, router_ifname, + family='inet6', scope='link') + + # Run radvd + router.fg('radvd', '-c', '-C', f'{confpath}') + radvd_cmd = ['radvd', '-C', f'{confpath}', '-n', + '-p', f'{pidfile}', '-d', '5'] + with router.bg(*radvd_cmd, privilege=True) as radvd: + yield NdpScenario(client=client, + ifname=IFNAME, + network=NETWORK, + gateway=router_ll.ip) + radvd.terminate() + + +def selftests() -> None: + NdpScenario.test(setup_radvd) -- 2.46.0
Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/dhcp.py | 190 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/tasst/dhcp.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index 92319d46..f94001e7 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -17,6 +17,7 @@ import exeter MODULES = [ 'cmdsite', + 'dhcp', 'ip', 'ndp', 'transfer', diff --git a/test/tasst/dhcp.py b/test/tasst/dhcp.py new file mode 100644 index 00000000..9231b086 --- /dev/null +++ b/test/tasst/dhcp.py @@ -0,0 +1,190 @@ +#! /usr/bin/env avocado-runner-avocado-classless + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +dhcp.py - Helpers for testing DHCP & DHCPv6 +""" + +import contextlib +import dataclasses +import ipaddress +import os +import tempfile +from typing import Iterator, Literal + +import exeter + +from . import cmdsite, ip, unshare, veth + + +DHCLIENT = '/sbin/dhclient' + + +def _dhclient(site: cmdsite.CmdSite, ifname: str, ipv: Literal['4', '6']) \ + -> Iterator[None]: + with tempfile.TemporaryDirectory() as tmpdir: + pidfile = os.path.join(tmpdir, 'dhclient.pid') + leasefile = os.path.join(tmpdir, 'dhclient.leases') + + # We need '-nc' because we may be running with + # capabilities but not UID 0. Without -nc dhclient drops + # capabilities before invoking dhclient-script, so it's + # unable to actually configure the interface + opts = [f'-{ipv}', '-v', '-nc', '-pf', f'{pidfile}', + '-lf', f'{leasefile}', f'{ifname}'] + site.fg(f'{DHCLIENT}', *opts, privilege=True) + yield + site.fg(f'{DHCLIENT}', '-x', '-pf', f'{pidfile}', privilege=True) + + +(a)contextlib.contextmanager +def dhclient4(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]: + yield from _dhclient(site, ifname, '4') + + +(a)contextlib.contextmanager +def dhclient6(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]: + yield from _dhclient(site, ifname, '6') + + +(a)dataclasses.dataclass +class Dhcp4Scenario(exeter.Scenario): + client: cmdsite.CmdSite + ifname: str + addr: ip.Addr + gateway: ip.Addr + mtu: int + + @exeter.scenariotest + def dhcp_addr(self) -> None: + with dhclient4(self.client, self.ifname): + (actual_addr,) = ip.addrs(self.client, self.ifname, + family='inet', scope='global') + exeter.assert_eq(actual_addr.ip, self.addr) + + @exeter.scenariotest + def dhcp_route(self) -> None: + with dhclient4(self.client, self.ifname): + (defroute,) = ip.routes4(self.client, dst='default') + exeter.assert_eq(ipaddress.ip_address(defroute['gateway']), + self.gateway) + + @exeter.scenariotest + def dhcp_mtu(self) -> None: + with dhclient4(self.client, self.ifname): + exeter.assert_eq(ip.mtu(self.client, self.ifname), self.mtu) + + +DHCPD = 'dhcpd' +IFNAME = 'clientif' + +SUBNET4 = ip.TEST_NET_1 +ipa4 = ip.IpiAllocator(SUBNET4) +(SERVER_IP4,) = ipa4.next_ipis() +(CLIENT_IP4,) = ipa4.next_ipis() + + +(a)contextlib.contextmanager +def setup_dhcpd_common(ifname: str, server_ifname: str) \ + -> Iterator[tuple[cmdsite.CmdSite, cmdsite.CmdSite, str]]: + with unshare.unshare('client', '-Un') as client, \ + unshare.unshare('server', '-n', + parent=client, privilege=True) as server, \ + veth.veth(client, ifname, server_ifname, server), \ + tempfile.TemporaryDirectory() as tmpdir: + yield (client, server, tmpdir) + + +def setup_dhcpd4() -> Iterator[Dhcp4Scenario]: + server_ifname = 'serverif' + + with setup_dhcpd_common(IFNAME, server_ifname) as (client, server, tmpdir): + # Configure dhcpd + confpath = os.path.join(tmpdir, 'dhcpd.conf') + open(confpath, 'w', encoding='UTF-8').write( + f'''subnet {SUBNET4.network_address} netmask {SUBNET4.netmask} {{ + option routers {SERVER_IP4.ip}; + range {CLIENT_IP4.ip} {CLIENT_IP4.ip}; + }}''' + ) + pidfile = os.path.join(tmpdir, 'dhcpd.pid') + leasepath = os.path.join(tmpdir, 'dhcpd.leases') + open(leasepath, 'wb').write(b'') + + ip.ifup(server, 'lo') + ip.ifup(server, server_ifname, SERVER_IP4) + + opts = ['-f', '-d', '-4', '-cf', f'{confpath}', + '-lf', f'{leasepath}', '-pf', f'{pidfile}'] + server.fg(f'{DHCPD}', '-t', *opts) # test config + with server.bg(f'{DHCPD}', *opts, privilege=True, + check=False) as dhcpd: + # Configure the client + ip.ifup(client, 'lo') + yield Dhcp4Scenario(client=client, ifname=IFNAME, + addr=CLIENT_IP4.ip, + gateway=SERVER_IP4.ip, mtu=1500) + dhcpd.terminate() + + +(a)dataclasses.dataclass +class Dhcp6Scenario(exeter.Scenario): + client: cmdsite.CmdSite + ifname: str + addr: ip.Addr + + @exeter.scenariotest + def dhcp6_addr(self) -> None: + with dhclient6(self.client, self.ifname): + addrs = [a.ip for a in ip.addrs(self.client, self.ifname, + family='inet6', + scope='global')] + assert self.addr in addrs # Might also have a SLAAC address + + +SUBNET6 = ip.TEST_NET6_TASST_A +ipa6 = ip.IpiAllocator(SUBNET6) +(SERVER_IP6,) = ipa6.next_ipis() +(CLIENT_IP6,) = ipa6.next_ipis() + + +def setup_dhcpd6() -> Iterator[Dhcp6Scenario]: + server_ifname = 'serverif' + + with setup_dhcpd_common(IFNAME, server_ifname) as (client, server, tmpdir): + # Sort out link local addressing + ip.ifup(server, 'lo') + ip.ifup(server, server_ifname, SERVER_IP6) + ip.ifup(client, 'lo') + ip.ifup(client, IFNAME) + ip.addr_wait(server, server_ifname, family='inet6', scope='link') + + # Configure the DHCP server + confpath = os.path.join(tmpdir, 'dhcpd.conf') + open(confpath, 'w', encoding='UTF-8').write( + f'''subnet6 {SUBNET6} {{ + range6 {CLIENT_IP6.ip} {CLIENT_IP6.ip}; + }}''') + pidfile = os.path.join(tmpdir, 'dhcpd.pid') + leasepath = os.path.join(tmpdir, 'dhcpd.leases') + open(leasepath, 'wb').write(b'') + + opts = ['-f', '-d', '-6', '-cf', f'{confpath}', + '-lf', f'{leasepath}', '-pf', f'{pidfile}'] + server.fg(f'{DHCPD}', '-t', *opts) # test config + with server.bg(f'{DHCPD}', *opts, privilege=True, + check=False) as dhcpd: + yield Dhcp6Scenario(client=client, ifname=IFNAME, + addr=CLIENT_IP6.ip) + dhcpd.terminate() + + +def selftests() -> None: + Dhcp4Scenario.test(setup_dhcpd4) + Dhcp6Scenario.test(setup_dhcpd6) -- 2.46.0
This constructs essentially the simplest sensible network for passt/pasta to operate in. We have one netns "simhost" to represent the host where we will run passt or pasta, and a second "gw" to represent its default gateway. Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/tasst/__main__.py | 1 + test/tasst/scenario/__init__.py | 12 ++++ test/tasst/scenario/simple.py | 108 ++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 test/tasst/scenario/__init__.py create mode 100644 test/tasst/scenario/simple.py diff --git a/test/tasst/__main__.py b/test/tasst/__main__.py index f94001e7..7e343492 100644 --- a/test/tasst/__main__.py +++ b/test/tasst/__main__.py @@ -20,6 +20,7 @@ MODULES = [ 'dhcp', 'ip', 'ndp', + 'scenario.simple', 'transfer', 'unshare', 'veth', diff --git a/test/tasst/scenario/__init__.py b/test/tasst/scenario/__init__.py new file mode 100644 index 00000000..4ea4584d --- /dev/null +++ b/test/tasst/scenario/__init__.py @@ -0,0 +1,12 @@ +#! /usr/bin/python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +scenario/ - Helpers to set up various sample network topologies +""" diff --git a/test/tasst/scenario/simple.py b/test/tasst/scenario/simple.py new file mode 100644 index 00000000..6ae3540a --- /dev/null +++ b/test/tasst/scenario/simple.py @@ -0,0 +1,108 @@ +#! /usr/bin/env python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +scenario/simple.py - Smallest sensible network to use passt/pasta +""" + +import contextlib +from typing import Iterator + +from .. import ip, transfer, unshare, veth + + +class __SimpleNet: # pylint: disable=R0903 + """A simple network setup scenario + + The sample network has 2 sites (network namespaces) connected with + a veth link: + [simhost] <-veth-> [gw] + + gw is set up as the default router for simhost. + + simhost has addresses: + self.IP4 (IPv4), self.IP6 (IPv6), self.ip6_ll (IPv6 link local) + + gw has addresses: + self.GW_IP4 (IPv4), self.GW_IP6 (IPv6), + self.gw_ip6_ll (IPv6 link local) + self.REMOTE_IP4 (IPv4), self.REMOTE_IP6 (IPv6) + + The "remote" addresses are on a different subnet from the others, + so the only way for simhost to reach them is via its default + route. This helps to exercise that we're actually using that, + rather than just local net routes. + + """ + + IFNAME = 'veth' + GW_IFNAME = 'gw' + IFNAME + ipa_local = ip.IpiAllocator() + (IP4, IP6) = ipa_local.next_ipis() + (GW_IP4, GW_IP6) = ipa_local.next_ipis() + + ipa_remote = ip.IpiAllocator(ip.TEST_NET_2, + ip.TEST_NET6_TASST_B) + (REMOTE_IP4, REMOTE_IP6) = ipa_remote.next_ipis() + + simhost: unshare.Unshare + gw: unshare.Unshare + + def __init__(self, simhost: unshare.Unshare, gw: unshare.Unshare) -> None: + self.simhost = simhost + self.gw = gw + + ip.ifup(self.gw, 'lo') + ip.ifup(self.gw, self.GW_IFNAME, self.GW_IP4, self.GW_IP6, + self.REMOTE_IP4, self.REMOTE_IP6) + + ip.ifup(simhost, 'lo') + ip.ifup(simhost, self.IFNAME, self.IP4, self.IP6) + + # Once link is up on both sides, SLAAC will run + self.gw_ip6_ll = ip.addr_wait(self.gw, self.GW_IFNAME, + family='inet6', scope='link')[0] + self.ip6_ll = ip.addr_wait(self.simhost, self.IFNAME, + family='inet6', scope='link')[0] + + # Set up the default route + self.simhost.fg('ip', '-4', 'route', 'add', 'default', + 'via', f'{self.GW_IP4.ip}', privilege=True) + self.simhost.fg('ip', '-6', 'route', 'add', 'default', + 'via', f'{self.gw_ip6_ll.ip}', 'dev', self.IFNAME, + privilege=True) + + +(a)contextlib.contextmanager +def simple_net() -> Iterator[__SimpleNet]: + with unshare.unshare('simhost', '-Ucnpf', '--mount-proc') as simhost, \ + unshare.unshare('gw', '-n', parent=simhost, privilege=True) as gw, \ + veth.veth(simhost, __SimpleNet.IFNAME, __SimpleNet.GW_IFNAME, gw): + yield __SimpleNet(simhost, gw) + + +def simple_transfer4() -> Iterator[transfer.TransferScenario]: + with simple_net() as snet: + yield transfer.TransferScenario(client=snet.simhost, + server=snet.gw, + connect_ip=snet.REMOTE_IP4.ip, + connect_port=10000) + + +def simple_transfer6() -> Iterator[transfer.TransferScenario]: + with simple_net() as snet: + yield transfer.TransferScenario(client=snet.simhost, + server=snet.gw, + connect_ip=snet.REMOTE_IP6.ip, + connect_port=10000) + + +def selftests() -> None: + transfer.TransferScenario.test(simple_transfer4) + transfer.TransferScenario.test(simple_transfer6) -- 2.46.0
Convert the old-style tests for pasta (DHCP, NDP, TCP and UDP transfers) to using avocado. There are a few differences in what we test, but this should generally improve coverage: * We run in a constructed network environment, so we no longer depend on the real host's networking configuration * We do independent setup for each individual test * We add explicit tests for --config-net, which we use to accelerate that setup for the TCP and UDP tests * The TCP and UDP tests now test transfers between the guest and a (simulated) remote site that's on a different network from the simulated pasta host. Thus testing the no NAT case that passt/pasta emphasizes. (We need to add tests for the NAT cases back in). Signed-off-by: David Gibson <david(a)gibson.dropbear.id.au> --- test/Makefile | 4 +- test/pasta/.gitignore | 1 + test/pasta/pasta.py | 130 ++++++++++++++++++++++++++++++++++++++++++ test/tasst/pasta.py | 48 ++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 test/pasta/.gitignore create mode 100644 test/pasta/pasta.py create mode 100644 test/tasst/pasta.py diff --git a/test/Makefile b/test/Makefile index 3ac67b66..23dcd368 100644 --- a/test/Makefile +++ b/test/Makefile @@ -64,11 +64,11 @@ LOCAL_ASSETS = mbuto.img mbuto.mem.img podman/bin/podman QEMU_EFI.fd \ $(TESTDATA_ASSETS) ASSETS = $(DOWNLOAD_ASSETS) $(LOCAL_ASSETS) -AVOCADO_ASSETS = +AVOCADO_ASSETS = nstool small.bin medium.bin big.bin META_ASSETS = nstool small.bin medium.bin big.bin EXETER_SH = build/static_checkers.sh -EXETER_PY = build/build.py +EXETER_PY = build/build.py pasta/pasta.py EXETER_JOBS = $(EXETER_SH:%.sh=%.json) $(EXETER_PY:%.py=%.json) AVOCADO_JOBS = $(EXETER_JOBS) avocado/static_checkers.json diff --git a/test/pasta/.gitignore b/test/pasta/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/test/pasta/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/test/pasta/pasta.py b/test/pasta/pasta.py new file mode 100644 index 00000000..b7d5ee2e --- /dev/null +++ b/test/pasta/pasta.py @@ -0,0 +1,130 @@ +#! /usr/bin/env avocado-runner-avocado-classless + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +avocado/pasta.py - Basic tests for pasta mode +""" + +import contextlib +import ipaddress +from typing import Any, Iterator + +import exeter + +import tasst +from tasst import cmdsite, dhcp, ndp, pasta, unshare +from tasst.scenario.simple import simple_net + +IN_FWD_PORT = 10002 +SPLICE_FWD_PORT = 10003 +FWD_OPTS = ['-t', f'{IN_FWD_PORT}', '-u', f'{IN_FWD_PORT}', + '-T', f'{SPLICE_FWD_PORT}', '-U', f'{SPLICE_FWD_PORT}'] + + +(a)contextlib.contextmanager +def pasta_unconfigured(*opts: str) -> Iterator[tuple[Any, unshare.Unshare]]: + with simple_net() as simnet: + with unshare.unshare('pastans', '-Ucnpf', '--mount-proc', + parent=simnet.simhost, privilege=True) \ + as guestns: + with pasta.pasta(simnet.simhost, guestns, *opts) as p: + yield simnet, p.ns + + +(a)exeter.test +def test_ifname() -> None: + with pasta_unconfigured() as (simnet, ns): + expected = set(['lo', simnet.IFNAME]) + exeter.assert_eq(set(tasst.ip.ifs(ns)), expected) + + +(a)ndp.NdpScenario.test +def pasta_ndp_setup() -> Iterator[ndp.NdpScenario]: + with pasta_unconfigured() as (simnet, guestns): + tasst.ip.ifup(guestns, simnet.IFNAME) + yield ndp.NdpScenario(client=guestns, + ifname=simnet.IFNAME, + network=simnet.IP6.network, + gateway=simnet.gw_ip6_ll.ip) + + +(a)dhcp.Dhcp4Scenario.test +def pasta_dhcp() -> Iterator[dhcp.Dhcp4Scenario]: + with pasta_unconfigured() as (simnet, guestns): + yield dhcp.Dhcp4Scenario(client=guestns, + ifname=simnet.IFNAME, + addr=simnet.IP4.ip, + gateway=simnet.GW_IP4.ip, + mtu=65520) + + +(a)dhcp.Dhcp6Scenario.test +def pasta_dhcpv6() -> Iterator[dhcp.Dhcp6Scenario]: + with pasta_unconfigured() as (simnet, guestns): + yield dhcp.Dhcp6Scenario(client=guestns, + ifname=simnet.IFNAME, + addr=simnet.IP6.ip) + + +(a)contextlib.contextmanager +def pasta_configured() -> Iterator[tuple[Any, unshare.Unshare]]: + with pasta_unconfigured('--config-net', *FWD_OPTS) as (simnet, ns): + # Wait for DAD to complete on the --config-net address + tasst.ip.addr_wait(ns, simnet.IFNAME, family='inet6', scope='global') + yield simnet, ns + + +(a)exeter.test +def test_config_net_addr() -> None: + with pasta_configured() as (simnet, ns): + addrs = tasst.ip.addrs(ns, simnet.IFNAME, scope='global') + exeter.assert_eq(set(addrs), set([simnet.IP4, simnet.IP6])) + + +(a)exeter.test +def test_config_net_route4() -> None: + with pasta_configured() as (simnet, ns): + (defroute,) = tasst.ip.routes4(ns, dst='default') + gateway = ipaddress.ip_address(defroute['gateway']) + exeter.assert_eq(gateway, simnet.GW_IP4.ip) + + +(a)exeter.test +def test_config_net_route6() -> None: + with pasta_configured() as (simnet, ns): + (defroute,) = tasst.ip.routes6(ns, dst='default') + gateway = ipaddress.ip_address(defroute['gateway']) + exeter.assert_eq(gateway, simnet.gw_ip6_ll.ip) + + +(a)exeter.test +def test_config_net_mtu() -> None: + with pasta_configured() as (simnet, ns): + mtu = tasst.ip.mtu(ns, simnet.IFNAME) + exeter.assert_eq(mtu, 65520) + + +(a)contextlib.contextmanager +def outward_transfer() -> Iterator[tuple[Any, cmdsite.CmdSite]]: + with pasta_configured() as (simnet, ns): + yield ns, simnet.gw + + +(a)contextlib.contextmanager +def inward_transfer() -> Iterator[tuple[Any, cmdsite.CmdSite]]: + with pasta_configured() as (simnet, ns): + yield simnet.gw, ns + + +(a)contextlib.contextmanager +def spliced_transfer() -> Iterator[tuple[cmdsite.CmdSite, cmdsite.CmdSite]]: + with pasta_configured() as (simnet, ns): + yield ns, simnet.simhost + + +if __name__ == '__main__': + exeter.main() diff --git a/test/tasst/pasta.py b/test/tasst/pasta.py new file mode 100644 index 00000000..e224b81b --- /dev/null +++ b/test/tasst/pasta.py @@ -0,0 +1,48 @@ +#! /usr/bin/python3 + +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Copyright Red Hat +# Author: David Gibson <david(a)gibson.dropbear.id.au> + +""" +Test A Simple Socket Transport + +pasta.py - Helpers for starting pasta +""" + +import contextlib +import os.path +import tempfile +from typing import Iterator + +from . import cmdsite, unshare + + +PASTA_BIN = '../pasta' + + +class _Pasta: + """A running pasta instance""" + + ns: unshare.Unshare + + def __init__(self, ns: unshare.Unshare): + self.ns = ns + + +(a)contextlib.contextmanager +def pasta(host: cmdsite.CmdSite, ns: unshare.Unshare, *opts: str) \ + -> Iterator[_Pasta]: + with tempfile.TemporaryDirectory() as piddir: + pidfile = os.path.join(piddir, 'pasta.pid') + relpid = ns.relative_pid(host) + cmd = [PASTA_BIN, '-f', '-P', pidfile] + list(opts) + [f'{relpid}'] + with host.bg(*cmd): + # Wait for the PID file to be written + pidstr = None + while not pidstr: + pidstr = host.output('cat', pidfile, check=False) + pid = int(pidstr) + yield _Pasta(ns) + host.fg('kill', '-TERM', f'{pid}') -- 2.46.0