public inbox for passt-dev@passt.top
 help / color / mirror / code / Atom feed
* [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests
@ 2024-08-26  2:09 David Gibson
  2024-08-26  2:09 ` [PATCH v3 01/15] test: run static checkers with Avocado and JSON definitions David Gibson
                   ` (14 more replies)
  0 siblings, 15 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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


^ permalink raw reply	[flat|nested] 16+ messages in thread

* [PATCH v3 01/15] test: run static checkers with Avocado and JSON definitions
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 02/15] test: Adjust how we invoke tests with run_avocado David Gibson
                   ` (13 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa

From: Cleber Rosa <crosa@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@redhat.com>
Message-ID: <20240629121342.3284907-1-crosa@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())
-- 
@@ -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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 02/15] test: Adjust how we invoke tests with run_avocado
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
  2024-08-26  2:09 ` [PATCH v3 01/15] test: run static checkers with Avocado and JSON definitions David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 03/15] test: Extend make targets to run Avocado tests David Gibson
                   ` (12 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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:
-- 
@@ -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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 03/15] test: Extend make targets to run Avocado tests
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
  2024-08-26  2:09 ` [PATCH v3 01/15] test: run static checkers with Avocado and JSON definitions David Gibson
  2024-08-26  2:09 ` [PATCH v3 02/15] test: Adjust how we invoke tests with run_avocado David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 04/15] test: Exeter based static tests David Gibson
                   ` (11 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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()
 
-- 
@@ -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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 04/15] test: Exeter based static tests
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (2 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 03/15] test: Extend make targets to run Avocado tests David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 05/15] tasst: Support library and linters for tests in Python David Gibson
                   ` (10 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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 "$@"
+
+
-- 
@@ -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@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 05/15] tasst: Support library and linters for tests in Python
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (3 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 04/15] test: Exeter based static tests David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 06/15] tasst/cmdsite: Base helpers for running shell commands in various places David Gibson
                   ` (9 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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@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@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@gibson.dropbear.id.au>
+
+"""
+Test A Simple Socket Transport
+library of test helpers for passt & pasta
+"""
+
+import exeter
+
+
+@exeter.test
+def placeholder() -> None:
+    pass
+
+
+if __name__ == '__main__':
+    exeter.main()
-- 
@@ -0,0 +1,22 @@
+#! /usr/bin/env python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@gibson.dropbear.id.au>
+
+"""
+Test A Simple Socket Transport
+library of test helpers for passt & pasta
+"""
+
+import exeter
+
+
+@exeter.test
+def placeholder() -> None:
+    pass
+
+
+if __name__ == '__main__':
+    exeter.main()
-- 
2.46.0


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 06/15] tasst/cmdsite: Base helpers for running shell commands in various places
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (4 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 05/15] tasst: Support library and linters for tests in Python David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 07/15] test: Add exeter+Avocado based build tests David Gibson
                   ` (8 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

Signed-off-by: David Gibson <david@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
 
-@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@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)
-- 
@@ -0,0 +1,193 @@
+#! /usr/bin/env python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 07/15] test: Add exeter+Avocado based build tests
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (5 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 06/15] tasst/cmdsite: Base helpers for running shell commands in various places David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 08/15] tasst/unshare: Add helpers to run commands in Linux unshared namespaces David Gibson
                   ` (7 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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
+
+
+@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'])
+
+
+@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()
-- 
@@ -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@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
+
+
+@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'])
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 08/15] tasst/unshare: Add helpers to run commands in Linux unshared namespaces
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (6 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 07/15] test: Add exeter+Avocado based build tests David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 09/15] tasst/ip: Helpers for configuring IPv4 and IPv6 David Gibson
                   ` (6 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

Use our existing nstool C helper, add python wrappers to easily run
commands in various namespaces.

Signed-off-by: David Gibson <david@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@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)
+
+
+@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)
-- 
@@ -0,0 +1,166 @@
+#! /usr/bin/env python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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)
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 09/15] tasst/ip: Helpers for configuring  IPv4 and IPv6
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (7 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 08/15] tasst/unshare: Add helpers to run commands in Linux unshared namespaces David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 10/15] tasst/veth: Helpers for constructing veth devices between namespaces David Gibson
                   ` (5 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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)
+
+
+@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)
+
+
+@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@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.
+"""
-- 
@@ -0,0 +1,16 @@
+#! /usr/bin/python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 10/15] tasst/veth: Helpers for constructing veth devices between namespaces
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (8 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 09/15] tasst/ip: Helpers for configuring IPv4 and IPv6 David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 11/15] tasst: Helpers to test transferring data between sites David Gibson
                   ` (4 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

Signed-off-by: David Gibson <david@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@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
+
+
+@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')
-- 
@@ -0,0 +1,80 @@
+#! /usr/bin/env python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 11/15] tasst: Helpers to test transferring data between sites
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (9 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 10/15] tasst/veth: Helpers for constructing veth devices between namespaces David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 12/15] tasst: Helpers for testing NDP behaviour David Gibson
                   ` (3 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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'
+
+
+@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)
-- 
@@ -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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 12/15] tasst: Helpers for testing NDP behaviour
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (10 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 11/15] tasst: Helpers to test transferring data between sites David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 13/15] tasst: Helpers for testing DHCP & DHCPv6 behaviour David Gibson
                   ` (2 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

Signed-iff-by: David Gibson <david@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@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
+
+
+@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)
-- 
@@ -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@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
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 13/15] tasst: Helpers for testing DHCP & DHCPv6 behaviour
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (11 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 12/15] tasst: Helpers for testing NDP behaviour David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 14/15] tasst: Helpers to construct a simple network environment for tests David Gibson
  2024-08-26  2:09 ` [PATCH v3 15/15] avocado: Convert basic pasta tests David Gibson
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

Signed-off-by: David Gibson <david@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@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)
+
+
+@contextlib.contextmanager
+def dhclient4(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]:
+    yield from _dhclient(site, ifname, '4')
+
+
+@contextlib.contextmanager
+def dhclient6(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]:
+    yield from _dhclient(site, ifname, '6')
+
+
+@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()
+
+
+@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()
+
+
+@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)
-- 
@@ -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@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)
+
+
+@contextlib.contextmanager
+def dhclient4(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]:
+    yield from _dhclient(site, ifname, '4')
+
+
+@contextlib.contextmanager
+def dhclient6(site: cmdsite.CmdSite, ifname: str) -> Iterator[None]:
+    yield from _dhclient(site, ifname, '6')
+
+
+@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()
+
+
+@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()
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 14/15] tasst: Helpers to construct a simple network environment for tests
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (12 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 13/15] tasst: Helpers for testing DHCP & DHCPv6 behaviour David Gibson
@ 2024-08-26  2:09 ` David Gibson
  2024-08-26  2:09 ` [PATCH v3 15/15] avocado: Convert basic pasta tests David Gibson
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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@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)
+
+
+@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)
-- 
@@ -0,0 +1,108 @@
+#! /usr/bin/env python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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)
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH v3 15/15] avocado: Convert basic pasta tests
  2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
                   ` (13 preceding siblings ...)
  2024-08-26  2:09 ` [PATCH v3 14/15] tasst: Helpers to construct a simple network environment for tests David Gibson
@ 2024-08-26  2:09 ` David Gibson
  14 siblings, 0 replies; 16+ messages in thread
From: David Gibson @ 2024-08-26  2:09 UTC (permalink / raw)
  To: Stefano Brivio, passt-dev; +Cc: Cleber Rosa, David Gibson

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@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@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}']
+
+
+@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
+
+
+@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)
+
+
+@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)
+
+
+@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)
+
+
+@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)
+
+
+@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
+
+
+@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]))
+
+
+@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)
+
+
+@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)
+
+
+@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)
+
+
+@contextlib.contextmanager
+def outward_transfer() -> Iterator[tuple[Any, cmdsite.CmdSite]]:
+    with pasta_configured() as (simnet, ns):
+        yield ns, simnet.gw
+
+
+@contextlib.contextmanager
+def inward_transfer() -> Iterator[tuple[Any, cmdsite.CmdSite]]:
+    with pasta_configured() as (simnet, ns):
+        yield simnet.gw, ns
+
+
+@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@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
+
+
+@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}')
-- 
@@ -0,0 +1,48 @@
+#! /usr/bin/python3
+
+# SPDX-License-Identifier: GPL-2.0-or-later
+#
+# Copyright Red Hat
+# Author: David Gibson <david@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
+
+
+@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


^ permalink raw reply related	[flat|nested] 16+ messages in thread

end of thread, other threads:[~2024-08-26  2:10 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-08-26  2:09 [PATCH v3 00/15] RFC: Proof-of-concept based exeter+Avocado tests David Gibson
2024-08-26  2:09 ` [PATCH v3 01/15] test: run static checkers with Avocado and JSON definitions David Gibson
2024-08-26  2:09 ` [PATCH v3 02/15] test: Adjust how we invoke tests with run_avocado David Gibson
2024-08-26  2:09 ` [PATCH v3 03/15] test: Extend make targets to run Avocado tests David Gibson
2024-08-26  2:09 ` [PATCH v3 04/15] test: Exeter based static tests David Gibson
2024-08-26  2:09 ` [PATCH v3 05/15] tasst: Support library and linters for tests in Python David Gibson
2024-08-26  2:09 ` [PATCH v3 06/15] tasst/cmdsite: Base helpers for running shell commands in various places David Gibson
2024-08-26  2:09 ` [PATCH v3 07/15] test: Add exeter+Avocado based build tests David Gibson
2024-08-26  2:09 ` [PATCH v3 08/15] tasst/unshare: Add helpers to run commands in Linux unshared namespaces David Gibson
2024-08-26  2:09 ` [PATCH v3 09/15] tasst/ip: Helpers for configuring IPv4 and IPv6 David Gibson
2024-08-26  2:09 ` [PATCH v3 10/15] tasst/veth: Helpers for constructing veth devices between namespaces David Gibson
2024-08-26  2:09 ` [PATCH v3 11/15] tasst: Helpers to test transferring data between sites David Gibson
2024-08-26  2:09 ` [PATCH v3 12/15] tasst: Helpers for testing NDP behaviour David Gibson
2024-08-26  2:09 ` [PATCH v3 13/15] tasst: Helpers for testing DHCP & DHCPv6 behaviour David Gibson
2024-08-26  2:09 ` [PATCH v3 14/15] tasst: Helpers to construct a simple network environment for tests David Gibson
2024-08-26  2:09 ` [PATCH v3 15/15] avocado: Convert basic pasta tests David Gibson

Code repositories for project(s) associated with this public inbox

	https://passt.top/passt

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for IMAP folder(s).