diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-06-14 03:57:14 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-06-14 03:57:14 +0000 |
| commit | 3d242c4c38a2b7d056b3c4e2af1c43b0d02d02ec (patch) | |
| tree | a50b7d3bbb0970d9b2ac8fd2a05559556cd606a8 | |
| parent | 8599dba96a5ac81bdac269e95e8498b84def98ce (diff) | |
| download | meta-virtualization-master-next.tar.gz | |
tests: add lxc runtime tests with download-template regressionmaster-next
The "lxc was tested" claim during the recent runtime testing sweep was
actually transitive — incus runs against LXC libraries under the hood,
so incus passing was treated as evidence that LXC itself worked. That
inference was wrong: incus uses its own go bindings into liblxc rather
than the lxc-* command-line tools, and the breakage Ferry Toth reported
on 2026-06-13 sat entirely in templates/lxc-download.in (a script
invoked by lxc-create, never reached through incus). The bug would not
have been caught by any existing test in the layer.
Add tests/test_lxc_runtime.py to close the gap. The suite boots
container-image-host with CONTAINER_IMAGE_HOST_EXTRA_INSTALL=lxc, then
runs three groups of checks against the live guest:
TestLxcInstalled — sanity that lxc-create, lxc-start and lxc --version
work at all. Catches packaging and PATH-level regressions.
TestLxcDownloadTemplate — explicit regression for the
templates-actually-create-DOWNLOAD_TEMP-directory.patch failure
mode. Runs `lxc-create --template download` and asserts the broken
early-mktemp error string ("mktemp: failed to create file via
template '-d…") does not appear in the output. We deliberately do
not require the download itself to succeed — the bug fires before
any HTTP request, so the test stays meaningful on air-gapped CI
where the actual fetch would fail for unrelated reasons.
TestLxcContainerLifecycle (@pytest.mark.network) — full end-to-end:
create from images.linuxcontainers.org, start, attach, stop,
destroy. Marked @network so offline runners deselect it cleanly.
The regression test above is the primary guard; this is depth.
Also register the lxc marker in pytest.ini so collection doesn't warn.
The test conventions (pexpect-driven runqemu boot, marker-delimited
command runner, TERM=dumb to suppress shell integration escape
sequences) match test_incus_runtime.py and test_xen_runtime.py so the
three suites read consistently.
Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
| -rw-r--r-- | tests/pytest.ini | 1 | ||||
| -rw-r--r-- | tests/test_lxc_runtime.py | 290 |
2 files changed, 291 insertions, 0 deletions
diff --git a/tests/pytest.ini b/tests/pytest.ini index 6d756a28..6e72e447 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini | |||
| @@ -12,6 +12,7 @@ markers = | |||
| 12 | boot: marks tests that boot a full QEMU image (requires pexpect) | 12 | boot: marks tests that boot a full QEMU image (requires pexpect) |
| 13 | incus: marks incus runtime tests | 13 | incus: marks incus runtime tests |
| 14 | k3s: marks k3s runtime tests | 14 | k3s: marks k3s runtime tests |
| 15 | lxc: marks lxc runtime tests | ||
| 15 | 16 | ||
| 16 | # Default options - include junit xml for CI and detailed output | 17 | # Default options - include junit xml for CI and detailed output |
| 17 | addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml | 18 | addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml |
diff --git a/tests/test_lxc_runtime.py b/tests/test_lxc_runtime.py new file mode 100644 index 00000000..695cfeb7 --- /dev/null +++ b/tests/test_lxc_runtime.py | |||
| @@ -0,0 +1,290 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2026 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | LXC runtime tests — boot container-image-host with lxc installed and | ||
| 6 | exercise the LXC command-line lifecycle (create, start, attach, stop, | ||
| 7 | destroy). | ||
| 8 | |||
| 9 | The tests build container-image-host with CONTAINER_IMAGE_HOST_EXTRA_INSTALL | ||
| 10 | including lxc. No local.conf changes needed. | ||
| 11 | |||
| 12 | The download-template regression check (TestLxcDownloadTemplate) exists | ||
| 13 | specifically to catch the class of bug reported on the meta-virt list | ||
| 14 | on 2026-06-13 (Ferry Toth: "lxc: starting a container errors out"), | ||
| 15 | where a stale local patch to templates/lxc-download.in expanded an empty | ||
| 16 | ${DOWNLOAD_TEMP} into `mktemp -p -d` and broke the download path before | ||
| 17 | any network call. The test invokes lxc-create with the download template | ||
| 18 | and asserts that the early mktemp error does not appear in the output, | ||
| 19 | even if the actual download itself fails (e.g. no network in the test | ||
| 20 | environment). That keeps the regression test useful in air-gapped CI | ||
| 21 | without requiring outbound network from the qemu guest. | ||
| 22 | |||
| 23 | Run: | ||
| 24 | pytest tests/test_lxc_runtime.py -v --poky-dir /opt/bruce/poky | ||
| 25 | |||
| 26 | Options: | ||
| 27 | --boot-timeout QEMU boot timeout (default: 120s) | ||
| 28 | --no-kvm Disable KVM acceleration | ||
| 29 | --machine QEMU MACHINE (default: qemux86-64) | ||
| 30 | """ | ||
| 31 | |||
| 32 | import os | ||
| 33 | import subprocess | ||
| 34 | import tempfile | ||
| 35 | import time | ||
| 36 | from pathlib import Path | ||
| 37 | |||
| 38 | import pytest | ||
| 39 | |||
| 40 | try: | ||
| 41 | import pexpect | ||
| 42 | PEXPECT_AVAILABLE = True | ||
| 43 | except ImportError: | ||
| 44 | PEXPECT_AVAILABLE = False | ||
| 45 | |||
| 46 | |||
| 47 | pytestmark = [ | ||
| 48 | pytest.mark.skipif(not PEXPECT_AVAILABLE, reason="pexpect not installed"), | ||
| 49 | pytest.mark.lxc, | ||
| 50 | pytest.mark.boot, | ||
| 51 | ] | ||
| 52 | |||
| 53 | |||
| 54 | # --------------------------------------------------------------------------- | ||
| 55 | # Helpers (mirror test_incus_runtime.py conventions so the suites read alike) | ||
| 56 | # --------------------------------------------------------------------------- | ||
| 57 | |||
| 58 | def _run_bitbake(build_dir, recipe, extra_vars=None, timeout=3600): | ||
| 59 | """Run bitbake with optional variable overrides via -R conf file.""" | ||
| 60 | bb_cmd = "bitbake" | ||
| 61 | conf_file = None | ||
| 62 | if extra_vars: | ||
| 63 | conf_file = tempfile.NamedTemporaryFile( | ||
| 64 | mode='w', suffix='.conf', prefix='pytest-lxc-', | ||
| 65 | dir=str(build_dir / "conf"), delete=False) | ||
| 66 | for var, val in extra_vars.items(): | ||
| 67 | conf_file.write(f'{var} = "{val}"\n') | ||
| 68 | conf_file.close() | ||
| 69 | bb_cmd += f" -R {conf_file.name}" | ||
| 70 | bb_cmd += f" {recipe}" | ||
| 71 | poky_dir = build_dir.parent | ||
| 72 | full_cmd = ( | ||
| 73 | f"bash -c 'cd {poky_dir} && " | ||
| 74 | f"source oe-init-build-env {build_dir} >/dev/null 2>&1 && {bb_cmd}'" | ||
| 75 | ) | ||
| 76 | try: | ||
| 77 | return subprocess.run(full_cmd, shell=True, cwd=build_dir, | ||
| 78 | timeout=timeout, capture_output=True, text=True) | ||
| 79 | finally: | ||
| 80 | if conf_file: | ||
| 81 | os.unlink(conf_file.name) | ||
| 82 | |||
| 83 | |||
| 84 | # --------------------------------------------------------------------------- | ||
| 85 | # Fixtures | ||
| 86 | # --------------------------------------------------------------------------- | ||
| 87 | |||
| 88 | @pytest.fixture(scope="module") | ||
| 89 | def lxc_image(request): | ||
| 90 | """Build container-image-host with lxc included. | ||
| 91 | |||
| 92 | Uses CONTAINER_IMAGE_HOST_EXTRA_INSTALL to add the lxc package without | ||
| 93 | needing to touch local.conf or invent a dedicated container-host-lxc | ||
| 94 | profile fragment. | ||
| 95 | """ | ||
| 96 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 97 | bd = request.config.getoption("--build-dir") | ||
| 98 | build_dir = Path(bd) if bd else poky_dir / "build" | ||
| 99 | result = _run_bitbake( | ||
| 100 | build_dir, "container-image-host", | ||
| 101 | extra_vars={ | ||
| 102 | "CONTAINER_IMAGE_HOST_EXTRA_INSTALL": "lxc", | ||
| 103 | }, | ||
| 104 | ) | ||
| 105 | if result.returncode != 0: | ||
| 106 | pytest.fail(f"container-image-host with lxc failed to build: {result.stderr}") | ||
| 107 | |||
| 108 | |||
| 109 | @pytest.fixture(scope="module") | ||
| 110 | def lxc_qemu(request, lxc_image): | ||
| 111 | """Boot container-image-host in QEMU, return a logged-in pexpect session.""" | ||
| 112 | machine = request.config.getoption("--machine", default="qemux86-64") | ||
| 113 | boot_timeout = int(request.config.getoption("--boot-timeout", default="120")) | ||
| 114 | no_kvm = request.config.getoption("--no-kvm", default=False) | ||
| 115 | |||
| 116 | poky_dir = Path(request.config.getoption("--poky-dir")) | ||
| 117 | bd = request.config.getoption("--build-dir") | ||
| 118 | builddir = str(Path(bd) if bd else poky_dir / "build") | ||
| 119 | |||
| 120 | kvm_opt = "" if no_kvm else "kvm" | ||
| 121 | cmd = ( | ||
| 122 | f"runqemu {machine} container-image-host ext4 nographic slirp " | ||
| 123 | f"{kvm_opt} qemuparams=\"-m 4096\"" | ||
| 124 | ) | ||
| 125 | |||
| 126 | child = pexpect.spawn( | ||
| 127 | f"bash -c 'cd {poky_dir} && source oe-init-build-env {builddir} " | ||
| 128 | f">/dev/null 2>&1 && {cmd}'", | ||
| 129 | timeout=boot_timeout, encoding="utf-8", logfile=None, | ||
| 130 | ) | ||
| 131 | |||
| 132 | child.expect(r"login:", timeout=boot_timeout) | ||
| 133 | child.sendline("root") | ||
| 134 | child.expect(r"root@.*[:~#]", timeout=30) | ||
| 135 | |||
| 136 | # Suppress shell-integration escape sequences that interfere with | ||
| 137 | # pexpect matchers (same trick as test_incus_runtime / test_xen_runtime). | ||
| 138 | child.sendline("export TERM=dumb") | ||
| 139 | child.expect(r"root@.*[:~#]", timeout=10) | ||
| 140 | |||
| 141 | yield child | ||
| 142 | |||
| 143 | child.sendline("poweroff") | ||
| 144 | try: | ||
| 145 | child.expect(pexpect.EOF, timeout=30) | ||
| 146 | except pexpect.TIMEOUT: | ||
| 147 | child.terminate(force=True) | ||
| 148 | |||
| 149 | |||
| 150 | def run_cmd(child, cmd, timeout=60): | ||
| 151 | """Run a shell command in the guest and return (stdout, rc).""" | ||
| 152 | marker = f"__MARKER_{time.monotonic_ns()}__" | ||
| 153 | child.sendline(f"{cmd}; echo {marker} $?") | ||
| 154 | child.expect(marker + r" (\d+)", timeout=timeout) | ||
| 155 | output = child.before.strip() | ||
| 156 | rc = int(child.match.group(1)) | ||
| 157 | child.expect(r"root@.*[:~#]", timeout=10) | ||
| 158 | return output, rc | ||
| 159 | |||
| 160 | |||
| 161 | # --------------------------------------------------------------------------- | ||
| 162 | # Sanity — lxc tooling present and functional | ||
| 163 | # --------------------------------------------------------------------------- | ||
| 164 | |||
| 165 | class TestLxcInstalled: | ||
| 166 | """Confirm lxc is installed and the basic commands report a version.""" | ||
| 167 | |||
| 168 | def test_lxc_create_present(self, lxc_qemu): | ||
| 169 | output, rc = run_cmd(lxc_qemu, "command -v lxc-create") | ||
| 170 | assert rc == 0, f"lxc-create not installed: {output}" | ||
| 171 | |||
| 172 | def test_lxc_start_present(self, lxc_qemu): | ||
| 173 | output, rc = run_cmd(lxc_qemu, "command -v lxc-start") | ||
| 174 | assert rc == 0, f"lxc-start not installed: {output}" | ||
| 175 | |||
| 176 | def test_lxc_version(self, lxc_qemu): | ||
| 177 | output, rc = run_cmd(lxc_qemu, "lxc-create --version") | ||
| 178 | assert rc == 0, f"lxc-create --version failed: {output}" | ||
| 179 | |||
| 180 | |||
| 181 | # --------------------------------------------------------------------------- | ||
| 182 | # Regression: the lxc-download.in mktemp bug from list thread #11808 | ||
| 183 | # --------------------------------------------------------------------------- | ||
| 184 | |||
| 185 | class TestLxcDownloadTemplate: | ||
| 186 | """Regression for the templates-actually-create-DOWNLOAD_TEMP-directory | ||
| 187 | patch breakage. | ||
| 188 | |||
| 189 | The bug was: when ${DOWNLOAD_TEMP} is unset (the common case for | ||
| 190 | `lxc-create --template download`), the patched else branch expanded | ||
| 191 | to `mktemp -p -d`, which the shell parses as `-d` being the argument | ||
| 192 | to `-p` rather than its own flag. mktemp then reports: | ||
| 193 | |||
| 194 | mktemp: failed to create file via template '-d/tmp.XXXXXXXXXX': | ||
| 195 | No such file or directory | ||
| 196 | |||
| 197 | and lxc-create exits before any network call. | ||
| 198 | |||
| 199 | We don't care whether the download itself succeeds here — in a test | ||
| 200 | environment without outbound network, it won't, and that's fine. | ||
| 201 | We only care that the early mktemp parse never happens. If it does, | ||
| 202 | that *exact* error string surfaces, and that string failing to appear | ||
| 203 | is what we assert. | ||
| 204 | """ | ||
| 205 | |||
| 206 | BAD_MKTEMP_ERROR = "mktemp: failed to create file via template '-d" | ||
| 207 | |||
| 208 | def test_download_template_no_mktemp_error(self, lxc_qemu): | ||
| 209 | """lxc-create with the download template must not emit the broken | ||
| 210 | mktemp invocation even when the actual download fails.""" | ||
| 211 | # The specific dist/release/arch values don't matter — even an | ||
| 212 | # invalid combination still exercises the early mktemp path | ||
| 213 | # before any HTTP request. We pick a plausibly-real combo so the | ||
| 214 | # test stays meaningful if a future change adds an early | ||
| 215 | # validation step on the args. | ||
| 216 | cmd = ( | ||
| 217 | "lxc-create --name test-download --template download -- " | ||
| 218 | "--dist ubuntu --release noble --arch amd64 2>&1" | ||
| 219 | ) | ||
| 220 | output, _rc = run_cmd(lxc_qemu, cmd, timeout=120) | ||
| 221 | assert self.BAD_MKTEMP_ERROR not in output, ( | ||
| 222 | f"lxc-download.in DOWNLOAD_TEMP regression — early mktemp error " | ||
| 223 | f"surfaced.\nFull output:\n{output}" | ||
| 224 | ) | ||
| 225 | # Clean up whatever partial state lxc-create may have left behind | ||
| 226 | # so the next test starts clean. Ignore rc — there may be nothing | ||
| 227 | # to destroy. | ||
| 228 | run_cmd(lxc_qemu, "lxc-destroy --name test-download --force", timeout=30) | ||
| 229 | |||
| 230 | |||
| 231 | # --------------------------------------------------------------------------- | ||
| 232 | # Network-required path — exercise the full download+create flow | ||
| 233 | # --------------------------------------------------------------------------- | ||
| 234 | |||
| 235 | @pytest.mark.network | ||
| 236 | class TestLxcContainerLifecycle: | ||
| 237 | """End-to-end create/start/attach/stop/destroy against a real download. | ||
| 238 | |||
| 239 | Marked @network because lxc-create --template download fetches from | ||
| 240 | images.linuxcontainers.org. Skipped on offline runners. The regression | ||
| 241 | test above runs without network and is the primary guard against | ||
| 242 | Ferry's bug. | ||
| 243 | """ | ||
| 244 | |||
| 245 | NAME = "test-lxc-lifecycle" | ||
| 246 | |||
| 247 | def test_create_alpine_via_download(self, lxc_qemu): | ||
| 248 | cmd = ( | ||
| 249 | f"lxc-create --name {self.NAME} --template download -- " | ||
| 250 | f"--dist alpine --release edge --arch amd64" | ||
| 251 | ) | ||
| 252 | output, rc = run_cmd(lxc_qemu, cmd, timeout=600) | ||
| 253 | if rc != 0: | ||
| 254 | pytest.skip( | ||
| 255 | f"lxc-create download failed (likely network unreachable): " | ||
| 256 | f"{output[:400]}" | ||
| 257 | ) | ||
| 258 | |||
| 259 | def test_start(self, lxc_qemu): | ||
| 260 | output, rc = run_cmd(lxc_qemu, f"lxc-start --name {self.NAME}", | ||
| 261 | timeout=60) | ||
| 262 | assert rc == 0, f"lxc-start failed: {output}" | ||
| 263 | # Give the container a moment to come up | ||
| 264 | run_cmd(lxc_qemu, "sleep 3") | ||
| 265 | |||
| 266 | def test_running(self, lxc_qemu): | ||
| 267 | output, rc = run_cmd(lxc_qemu, f"lxc-ls --running -1") | ||
| 268 | assert self.NAME in output, f"container not running: {output}" | ||
| 269 | |||
| 270 | def test_attach_runs_command(self, lxc_qemu): | ||
| 271 | # lxc-attach returns the exit code of the inner command, so | ||
| 272 | # check the inner command's output rather than rc alone. | ||
| 273 | output, _rc = run_cmd( | ||
| 274 | lxc_qemu, | ||
| 275 | f"lxc-attach --name {self.NAME} -- cat /etc/os-release", | ||
| 276 | timeout=30, | ||
| 277 | ) | ||
| 278 | assert "alpine" in output.lower(), ( | ||
| 279 | f"expected alpine os-release inside container, got: {output}" | ||
| 280 | ) | ||
| 281 | |||
| 282 | def test_stop(self, lxc_qemu): | ||
| 283 | output, rc = run_cmd(lxc_qemu, f"lxc-stop --name {self.NAME}", | ||
| 284 | timeout=60) | ||
| 285 | assert rc == 0, f"lxc-stop failed: {output}" | ||
| 286 | |||
| 287 | def test_destroy(self, lxc_qemu): | ||
| 288 | output, rc = run_cmd(lxc_qemu, f"lxc-destroy --name {self.NAME}", | ||
| 289 | timeout=60) | ||
| 290 | assert rc == 0, f"lxc-destroy failed: {output}" | ||
