From fbe3992fd5ded6af44d4517046c0ab255775e132 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 5 Dec 2023 07:51:16 +0100 Subject: [PATCH] test: boot generated VM and wait for ssh port --- .github/workflows/tests.yml | 2 +- test/test_smoke.py | 8 +++- test/test_vm.py | 6 +++ test/vm.py | 78 +++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 test/test_vm.py create mode 100644 test/vm.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b6bb4897..ffcf28b43 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,7 +69,7 @@ jobs: uses: actions/setup-python@v4 - name: Install test dependencies run: | - sudo apt install -y podman python3-pytest flake8 + sudo apt install -y podman python3-pytest flake8 qemu-system-x86 - name: Run tests run: | # podman needs (parts of) the environment but will break when diff --git a/test/test_smoke.py b/test/test_smoke.py index 174b1b07e..18858cd56 100644 --- a/test/test_smoke.py +++ b/test/test_smoke.py @@ -7,6 +7,7 @@ # local test utils import testutil +from vm import VM @pytest.fixture(name="output_path") @@ -63,5 +64,8 @@ def test_smoke(output_path, config_json): assert journal_output != "" generated_img = pathlib.Path(output_path) / "qcow2/disk.qcow2" assert generated_img.exists(), f"output file missing, dir content: {os.listdir(os.fspath(output_path))}" - # TODO: boot and do basic checks, see - # https://github.com/osbuild/osbuild-deploy-container/compare/main...mvo5:integration-test?expand=1 + with VM(generated_img) as test_vm: + test_vm.start() + # TODO: login once user creation in osbuild-deploy-container is ready + ready = test_vm.wait_ssh_ready() + assert ready diff --git a/test/test_vm.py b/test/test_vm.py new file mode 100644 index 000000000..e1a06d68a --- /dev/null +++ b/test/test_vm.py @@ -0,0 +1,6 @@ +from vm import get_free_port + + +def test_get_free_port(): + port_nr = get_free_port() + assert port_nr > 1024 and port_nr < 65535 diff --git a/test/vm.py b/test/vm.py new file mode 100644 index 000000000..72a332f70 --- /dev/null +++ b/test/vm.py @@ -0,0 +1,78 @@ +import pathlib +import subprocess +import sys +import socket +import time + + +def get_free_port() -> int: + # this is racy but there is no race-free way to do better with the qemu CLI + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("localhost", 0)) + return s.getsockname()[1] + + +class VM: + MEM = "2000" + + def __init__(self, img, snapshot=True): + self._img = pathlib.Path(img) + self._qemu_p = None + self._ssh_port = None + self._snapshot = snapshot + + def __del__(self): + self.force_stop() + + def start(self): + log_path = self._img.with_suffix(".serial-log") + self._ssh_port = get_free_port() + qemu_cmdline = [ + "qemu-system-x86_64", "-enable-kvm", + "-m", self.MEM, + # get "illegal instruction" inside the VM otherwise + "-cpu", "host", + "-serial", f"file:{log_path}", + "-netdev", f"user,id=net.0,hostfwd=tcp::{self._ssh_port}-:22", + "-device", "rtl8139,netdev=net.0", + ] + if self._snapshot: + qemu_cmdline.append("-snapshot") + qemu_cmdline.append(self._img) + self._log(f"vm starting, log available at {log_path}") + + # XXX: use systemd-run to ensure cleanup? + self._qemu_p = subprocess.Popen( + qemu_cmdline, stdout=sys.stdout, stderr=sys.stderr) + # XXX: also check that qemu is working and did not crash + self.wait_ssh_ready() + self._log(f"vm ready at port {self._ssh_port}") + + def _log(self, msg): + # XXX: use a proper logger + sys.stdout.write(msg.rstrip("\n") + "\n") + + def wait_ssh_ready(self, max_wait=120): + sleep = 5 + for i in range(max_wait // sleep): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.connect(("localhost", self._ssh_port)) + data = s.recv(256) + if b"OpenSSH" in data: + return True + except ConnectionRefusedError: + time.sleep(sleep) + raise ConnectionRefusedError("cannot connect to {self._ssh_port} after {maxwait}") + + def force_stop(self): + if self._qemu_p: + self._qemu_p.kill() + self._qemu_p = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, type, value, tb): + self.force_stop()