public inbox for passt-dev@passt.top
 help / color / mirror / code / Atom feed
From: David Gibson <david@gibson.dropbear.id.au>
To: Stefano Brivio <sbrivio@redhat.com>, passt-dev@passt.top
Cc: Cleber Rosa <crosa@redhat.com>,
	David Gibson <david@gibson.dropbear.id.au>
Subject: [PATCH v3 06/15] tasst/cmdsite: Base helpers for running shell commands in various places
Date: Mon, 26 Aug 2024 12:09:33 +1000	[thread overview]
Message-ID: <20240826020942.545155-7-david@gibson.dropbear.id.au> (raw)
In-Reply-To: <20240826020942.545155-1-david@gibson.dropbear.id.au>

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


  parent reply	other threads:[~2024-08-26  2:09 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 ` David Gibson [this message]
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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20240826020942.545155-7-david@gibson.dropbear.id.au \
    --to=david@gibson.dropbear.id.au \
    --cc=crosa@redhat.com \
    --cc=passt-dev@passt.top \
    --cc=sbrivio@redhat.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
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).