From e8489e104ab8bc0ba0153e3acbbcdf578d4858f5 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 +- plans/all.fmf | 3 ++ test/test_smoke.py | 9 +++-- test/testutil.py | 23 +++++++++++++ test/testutil_test.py | 26 +++++++++++++++ test/vm.py | 66 +++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 test/testutil_test.py create mode 100644 test/vm.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e548fc58b..0e97b1a90 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/plans/all.fmf b/plans/all.fmf index 60768f986..2dedd0b86 100644 --- a/plans/all.fmf +++ b/plans/all.fmf @@ -2,6 +2,9 @@ summary: Run all tests inside a VM environment provision: how: virtual image: fedora:39 + hardware: + virtualisation: + is-supported: true prepare: how: install package: diff --git a/test/test_smoke.py b/test/test_smoke.py index 7bed22872..0d3855f7f 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") @@ -53,7 +54,7 @@ def test_smoke(output_path, config_json): "--security-opt", "label=type:unconfined_t", "-v", f"{output_path}:/output", "bootc-image-builder-test", - "quay.io/centos-bootc/centos-bootc:stream9", + "quay.io/centos-bootc/fedora-bootc:eln", "--config", "/output/config.json", ]) # check that there are no denials @@ -63,5 +64,7 @@ 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/bootc-image-builder/compare/main...mvo5:integration-test?expand=1 + with VM(generated_img) as test_vm: + # TODO: replace with 'test_vm.run("true")' once user creation via + # blueprints works + test_vm.wait_ssh_ready() diff --git a/test/testutil.py b/test/testutil.py index 47da18e03..7fd5c24f4 100644 --- a/test/testutil.py +++ b/test/testutil.py @@ -1,5 +1,7 @@ +import socket import shutil import subprocess +import time def journal_cursor(): @@ -15,3 +17,24 @@ def journal_after_cursor(cursor): def has_executable(name): return shutil.which(name) is not None + + +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] + + +def wait_ssh_ready(port, sleep, max_wait_sec): + for i in range(int(max_wait_sec / sleep)): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(sleep) + try: + s.connect(("localhost", port)) + data = s.recv(256) + if b"OpenSSH" in data: + return + except (ConnectionRefusedError, TimeoutError): + time.sleep(sleep) + raise ConnectionRefusedError(f"cannot connect to port {port} after {max_wait_sec}s") diff --git a/test/testutil_test.py b/test/testutil_test.py new file mode 100644 index 000000000..e416a0592 --- /dev/null +++ b/test/testutil_test.py @@ -0,0 +1,26 @@ +import contextlib +import subprocess +from unittest.mock import call, patch + +import pytest + +from testutil import has_executable, get_free_port, wait_ssh_ready + + +def test_get_free_port(): + port_nr = get_free_port() + assert port_nr > 1024 and port_nr < 65535 + + +@pytest.mark.skipif(not has_executable("nc"), reason="needs nc") +@patch("time.sleep") +def test_wait_ssh_ready(mocked_sleep): + port = get_free_port() + with pytest.raises(ConnectionRefusedError): + wait_ssh_ready(port, sleep=0.1, max_wait_sec=0.35) + assert mocked_sleep.call_args_list == [call(0.1), call(0.1), call(0.1)] + # now make port ready + with contextlib.ExitStack() as cm: + p = subprocess.Popen(f"echo OpenSSH | nc -l {port}", shell=True) + cm.callback(p.kill) + wait_ssh_ready(port, sleep=0.1, max_wait_sec=10) diff --git a/test/vm.py b/test/vm.py new file mode 100644 index 000000000..78938e13e --- /dev/null +++ b/test/vm.py @@ -0,0 +1,66 @@ +import pathlib +import subprocess +import sys + +from testutil import get_free_port, wait_ssh_ready + + +class VM: + MEM = "2000" + QEMU = "qemu-system-x86_64" + + 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): + if self._qemu_p is not None: + return + log_path = self._img.with_suffix(".serial-log") + self._ssh_port = get_free_port() + qemu_cmdline = [ + self.QEMU, "-enable-kvm", + "-m", self.MEM, + # get "illegal instruction" inside the VM otherwise + "-cpu", "host", + "-nographic", + "-serial", "stdio", + "-monitor", "none", + "-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): + wait_ssh_ready(self._ssh_port, sleep=1, max_wait_sec=600) + + 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()