summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--tests/pytest.ini1
-rw-r--r--tests/test_lxc_runtime.py290
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
17addopts = -v --tb=short --junitxml=/tmp/pytest-results.xml 18addopts = -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"""
5LXC runtime tests — boot container-image-host with lxc installed and
6exercise the LXC command-line lifecycle (create, start, attach, stop,
7destroy).
8
9The tests build container-image-host with CONTAINER_IMAGE_HOST_EXTRA_INSTALL
10including lxc. No local.conf changes needed.
11
12The download-template regression check (TestLxcDownloadTemplate) exists
13specifically to catch the class of bug reported on the meta-virt list
14on 2026-06-13 (Ferry Toth: "lxc: starting a container errors out"),
15where 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
17any network call. The test invokes lxc-create with the download template
18and asserts that the early mktemp error does not appear in the output,
19even if the actual download itself fails (e.g. no network in the test
20environment). That keeps the regression test useful in air-gapped CI
21without requiring outbound network from the qemu guest.
22
23Run:
24 pytest tests/test_lxc_runtime.py -v --poky-dir /opt/bruce/poky
25
26Options:
27 --boot-timeout QEMU boot timeout (default: 120s)
28 --no-kvm Disable KVM acceleration
29 --machine QEMU MACHINE (default: qemux86-64)
30"""
31
32import os
33import subprocess
34import tempfile
35import time
36from pathlib import Path
37
38import pytest
39
40try:
41 import pexpect
42 PEXPECT_AVAILABLE = True
43except ImportError:
44 PEXPECT_AVAILABLE = False
45
46
47pytestmark = [
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
58def _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")
89def 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")
110def 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
150def 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
165class 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
185class 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
236class 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}"