summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorBruce Ashfield <bruce.ashfield@gmail.com>2026-02-24 16:53:24 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2026-02-26 01:05:02 +0000
commitefccccda627426319d4ced3b8c8467962a69e30c (patch)
treedc4e1ee3c9ecc2dbf0f9d5679eb5d7560635b41c /tests
parent90d6712b3bead6fc6def7050787b5c4b2ce03260 (diff)
downloadmeta-virtualization-efccccda627426319d4ced3b8c8467962a69e30c.tar.gz
xen: add runtime boot tests for hypervisor, guest bundling, vxn and containerd
New test_xen_runtime.py boots xen-image-minimal via runqemu and verifies: - Xen hypervisor running (xl list, dmesg, Dom0 memory cap) - Bundled guest autostart (alpine visible in xl list) - vxn standalone (vxn run --rm alpine echo hello) - containerd/vctr integration (ctr pull + vctr run) Uses pexpect-based XenRunner with module-scoped fixture (boot once, run all tests). TERM=dumb set after login to suppress terminal UI from ctr/vxn progress bars. Free memory check skips vxn/vctr tests gracefully when insufficient Xen memory available. Also registers 'boot' marker in conftest.py and documents build prerequisites, test options and skip behavior in README.md. Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'tests')
-rw-r--r--tests/README.md77
-rw-r--r--tests/conftest.py3
-rw-r--r--tests/test_xen_runtime.py423
3 files changed, 502 insertions, 1 deletions
diff --git a/tests/README.md b/tests/README.md
index 09bc70ca..886a33ec 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,9 +1,10 @@
1# Tests for vdkr, vpdmn and container-cross-install 1# Tests for vdkr, vpdmn, container-cross-install and Xen runtime
2 2
3Pytest-based test suite for: 3Pytest-based test suite for:
4- **vdkr**: Docker CLI for cross-architecture emulation 4- **vdkr**: Docker CLI for cross-architecture emulation
5- **vpdmn**: Podman CLI for cross-architecture emulation 5- **vpdmn**: Podman CLI for cross-architecture emulation
6- **container-cross-install**: Yocto container bundling system 6- **container-cross-install**: Yocto container bundling system
7- **xen-runtime**: Xen hypervisor boot and runtime verification
7 8
8## Requirements 9## Requirements
9 10
@@ -346,6 +347,75 @@ pytest tests/test_container_cross_install.py::TestContainerCrossClass -v
346 347
347--- 348---
348 349
350## Xen Runtime Tests
351
352Xen runtime tests boot an actual xen-image-minimal in QEMU and verify the Xen hypervisor is functional end-to-end. Tests detect available features inside Dom0 and skip gracefully when optional components are not installed.
353
354### Build Prerequisites
355
356| Test Tier | What to Add to `local.conf` | Build Command |
357|-----------|---------------------------|---------------|
358| **Dom0 boot** (core) | `DISTRO_FEATURES:append = " xen systemd"` | `bitbake xen-image-minimal` |
359| **Guest bundling** | `IMAGE_INSTALL:append:pn-xen-image-minimal = " alpine-xen-guest-bundle"` | `bitbake xen-image-minimal` |
360| **vxn/containerd** | `DISTRO_FEATURES:append = " virtualization vcontainer vxn"` and `IMAGE_INSTALL:append:pn-xen-image-minimal = " vxn"` and `BBMULTICONFIG = "vruntime-aarch64 vruntime-x86-64"` | `bitbake xen-image-minimal` |
361
362The test locates the image at: `{build_dir}/tmp/deploy/images/{machine}/xen-image-minimal-{machine}.rootfs.wic`
363
364### What the Tests Check
365
366| Test Class | What It Tests | What's Needed |
367|------------|---------------|---------------|
368| `TestXenDom0Boot` | Hypervisor running, xl list, dmesg, memory cap | Core Xen image |
369| `TestXenGuestBundleRuntime` | Bundled guests visible in xl list, xendomains active | Guest bundle packages |
370| `TestXenVxnStandalone` | vxn binary present, `vxn run` works | vxn in image + network |
371| `TestXenContainerd` | containerd active, ctr pull + vctr run | containerd + vctr + network |
372
373### Running Xen Runtime Tests
374
375```bash
376cd /opt/bruce/poky/meta-virtualization
377
378# All Xen runtime tests (requires built image + KVM)
379pytest tests/test_xen_runtime.py -v --machine qemux86-64
380
381# Skip network-dependent vxn/containerd tests
382pytest tests/test_xen_runtime.py -v -m "boot and not network"
383
384# Custom paths and longer timeout
385pytest tests/test_xen_runtime.py -v \
386 --poky-dir /opt/bruce/poky \
387 --build-dir /opt/bruce/poky/build \
388 --boot-timeout 180
389
390# Disable KVM (slower, but works in VMs)
391pytest tests/test_xen_runtime.py -v --no-kvm
392```
393
394### Xen Runtime Test Options
395
396| Option | Default | Description |
397|--------|---------|-------------|
398| `--poky-dir PATH` | /opt/bruce/poky | Path to poky directory |
399| `--build-dir PATH` | $POKY_DIR/build | Path to build directory |
400| `--machine MACHINE` | qemux86-64 | Target machine (qemux86-64 or qemuarm64) |
401| `--boot-timeout SECS` | 120 | Timeout for boot to complete |
402| `--no-kvm` | (KVM enabled) | Disable KVM acceleration |
403
404### Skip Behavior
405
406Tests detect what's installed inside Dom0 and skip gracefully:
407
408- **No .wic image** → All tests skip: `"xen-image-minimal .wic image not found"`
409- **No pexpect** → All tests skip: `"pexpect not installed"`
410- **Boot fails** → All tests skip: `"Failed to boot Xen image"`
411- **No bundled guests** → Guest tests skip: `"No bundled guests detected"`
412- **No xendomains** → xendomains test skips: `"xendomains service not installed"`
413- **No vxn** → vxn tests skip: `"vxn not installed in image"`
414- **No containerd** → containerd tests skip: `"containerd not installed"`
415- **No vctr** → vctr test skips: `"vctr not installed in image"`
416
417---
418
349## Capturing Test Output 419## Capturing Test Output
350 420
351Test output is automatically captured to files for debugging: 421Test output is automatically captured to files for debugging:
@@ -459,6 +529,11 @@ tests/
459│ ├── TestMultiLayerOCIClass # OCI_LAYERS support 529│ ├── TestMultiLayerOCIClass # OCI_LAYERS support
460│ ├── TestMultiLayerOCIBuild # layer build verification 530│ ├── TestMultiLayerOCIBuild # layer build verification
461│ └── TestLayerCaching # layer cache tests 531│ └── TestLayerCaching # layer cache tests
532├── test_xen_runtime.py # Xen runtime boot tests
533│ ├── TestXenDom0Boot # hypervisor running, xl list, dmesg
534│ ├── TestXenGuestBundleRuntime # bundled guests, xendomains
535│ ├── TestXenVxnStandalone # vxn run (network)
536│ └── TestXenContainerd # containerd + vctr (network)
462└── README.md # This file 537└── README.md # This file
463``` 538```
464 539
diff --git a/tests/conftest.py b/tests/conftest.py
index d21f237c..5f54c369 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -586,6 +586,9 @@ def pytest_configure(config):
586 config.addinivalue_line( 586 config.addinivalue_line(
587 "markers", "secure: marks tests that require secure registry mode (TLS/auth)" 587 "markers", "secure: marks tests that require secure registry mode (TLS/auth)"
588 ) 588 )
589 config.addinivalue_line(
590 "markers", "boot: marks tests that boot a QEMU image (requires built image)"
591 )
589 592
590 593
591@pytest.fixture 594@pytest.fixture
diff --git a/tests/test_xen_runtime.py b/tests/test_xen_runtime.py
new file mode 100644
index 00000000..697c57ee
--- /dev/null
+++ b/tests/test_xen_runtime.py
@@ -0,0 +1,423 @@
1# SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield
2#
3# SPDX-License-Identifier: MIT
4"""
5Xen runtime boot tests - boot xen-image-minimal and verify hypervisor.
6
7These tests boot an actual Xen Dom0 image via runqemu, verify the
8hypervisor is functional, check guest bundling, and exercise vxn/containerd.
9
10Build prerequisites (minimum for Dom0 boot tests):
11 DISTRO_FEATURES:append = " xen systemd"
12 MACHINE = "qemux86-64" # or qemuarm64
13 bitbake xen-image-minimal
14
15For guest bundling tests:
16 IMAGE_INSTALL:append:pn-xen-image-minimal = " alpine-xen-guest-bundle"
17
18For vxn/containerd tests:
19 DISTRO_FEATURES:append = " virtualization vcontainer vxn"
20 IMAGE_INSTALL:append:pn-xen-image-minimal = " vxn"
21 BBMULTICONFIG = "vruntime-aarch64 vruntime-x86-64"
22
23Run with:
24 pytest tests/test_xen_runtime.py -v --machine qemux86-64
25
26Skip network-dependent tests:
27 pytest tests/test_xen_runtime.py -v -m "boot and not network"
28
29Custom paths and longer timeout:
30 pytest tests/test_xen_runtime.py -v \
31 --poky-dir /opt/bruce/poky \
32 --build-dir /opt/bruce/poky/build \
33 --boot-timeout 180
34"""
35
36import re
37import time
38import pytest
39from pathlib import Path
40
41# Optional import for boot tests
42try:
43 import pexpect
44 PEXPECT_AVAILABLE = True
45except ImportError:
46 PEXPECT_AVAILABLE = False
47
48
49# Note: Command line options (--poky-dir, --build-dir, --machine, --boot-timeout, --no-kvm)
50# are defined in conftest.py to avoid conflicts with other test files.
51
52
53class XenRunner:
54 """
55 Manages a runqemu session for Xen boot testing.
56
57 Uses pexpect to interact with the serial console of a booted
58 xen-image-minimal via runqemu.
59 """
60
61 def __init__(self, poky_dir, build_dir, machine, use_kvm=True, timeout=120):
62 self.poky_dir = Path(poky_dir)
63 self.build_dir = Path(build_dir)
64 self.machine = machine
65 self.use_kvm = use_kvm
66 self.timeout = timeout
67 self.child = None
68 self.booted = False
69
70 def start(self):
71 """Start runqemu and wait for login prompt."""
72 if not PEXPECT_AVAILABLE:
73 raise RuntimeError("pexpect not installed. Run: pip install pexpect")
74
75 kvm_opt = "kvm" if self.use_kvm else ""
76 cmd = (
77 f"bash -c 'cd {self.poky_dir} && "
78 f"source oe-init-build-env {self.build_dir} >/dev/null 2>&1 && "
79 f"runqemu {self.machine} xen-image-minimal wic nographic slirp {kvm_opt} "
80 f"qemuparams=\"-m 4096\"'"
81 )
82
83 print(f"Starting runqemu (Xen): {cmd}")
84 self.child = pexpect.spawn(cmd, encoding='utf-8', timeout=self.timeout)
85
86 # Log output for debugging
87 self.child.logfile_read = open('/tmp/runqemu-xen-test.log', 'w')
88
89 # Wait for login prompt
90 try:
91 index = self.child.expect([
92 r'login:',
93 r'root@', # Already logged in
94 pexpect.TIMEOUT,
95 pexpect.EOF,
96 ], timeout=self.timeout)
97
98 if index == 0:
99 self.child.sendline('root')
100 self.child.expect([r'root@', r'#', r'\$'], timeout=30)
101 self.booted = True
102 elif index == 1:
103 self.booted = True
104
105 if self.booted:
106 # Disable terminal UI (progress bars, cursor movement) from
107 # tools like ctr, vxn, vctr that use fancy terminal output
108 self.child.sendline('export TERM=dumb')
109 self.child.expect(r'root@[^:]+:[^#]+#', timeout=10)
110
111 if index == 2:
112 raise RuntimeError(f"Timeout waiting for login (>{self.timeout}s)")
113 elif index == 3:
114 raise RuntimeError("runqemu terminated unexpectedly")
115
116 except Exception as e:
117 self.stop()
118 raise RuntimeError(f"Failed to boot Xen image: {e}")
119
120 return self
121
122 @staticmethod
123 def _strip_escape_sequences(text):
124 """Strip ANSI and OSC escape sequences from terminal output."""
125 # OSC sequences: ESC ] ... ESC \ or ESC ] ... BEL
126 text = re.sub(r'\x1b\][^\x1b\x07]*(?:\x1b\\|\x07)', '', text)
127 # CSI sequences: ESC [ ... final_byte
128 text = re.sub(r'\x1b\[[0-9;]*[A-Za-z]', '', text)
129 # Any remaining bare ESC sequences
130 text = re.sub(r'\x1b[^[\]].?', '', text)
131 return text
132
133 def run_command(self, cmd, timeout=60):
134 """Run a command and return the output."""
135 if not self.booted:
136 raise RuntimeError("System not booted")
137
138 # Wait for prompt to be ready
139 time.sleep(0.3)
140
141 self.child.sendline(cmd)
142
143 try:
144 self.child.expect(r'root@[^:]+:[^#]+#', timeout=timeout)
145 raw_output = self.child.before
146
147 # Strip terminal escape sequences (OSC 3008 shell integration, etc.)
148 raw_output = self._strip_escape_sequences(raw_output)
149
150 # Parse: split by newlines, skip command echo
151 lines = raw_output.replace('\r', '').split('\n')
152
153 output_lines = []
154 for i, line in enumerate(lines):
155 stripped = line.strip()
156 if not stripped:
157 continue
158 # First non-empty line is usually the command echo
159 if i == 0 or (output_lines == [] and cmd[:10] in line):
160 continue
161 output_lines.append(stripped)
162
163 return '\n'.join(output_lines)
164
165 except pexpect.TIMEOUT:
166 print(f"[TIMEOUT] Command '{cmd}' timed out after {timeout}s")
167 return ""
168
169 def stop(self):
170 """Shutdown the QEMU instance."""
171 if self.child:
172 try:
173 if self.booted:
174 self.child.sendline('poweroff')
175 time.sleep(2)
176
177 if self.child.isalive():
178 self.child.terminate(force=True)
179 except Exception:
180 pass
181 finally:
182 if self.child.logfile_read:
183 self.child.logfile_read.close()
184 self.child = None
185 self.booted = False
186
187
188# ============================================================================
189# Fixtures
190# ============================================================================
191
192@pytest.fixture(scope="module")
193def poky_dir(request):
194 """Path to poky directory."""
195 path = Path(request.config.getoption("--poky-dir"))
196 if not path.exists():
197 pytest.skip(f"Poky directory not found: {path}")
198 return path
199
200
201@pytest.fixture(scope="module")
202def build_dir(request, poky_dir):
203 """Path to build directory."""
204 bd = request.config.getoption("--build-dir")
205 if bd:
206 path = Path(bd)
207 else:
208 path = poky_dir / "build"
209 if not path.exists():
210 pytest.skip(f"Build directory not found: {path}")
211 return path
212
213
214@pytest.fixture(scope="module")
215def machine(request):
216 """Target machine."""
217 return request.config.getoption("--machine")
218
219
220@pytest.fixture(scope="module")
221def xen_session(request, poky_dir, build_dir, machine):
222 """
223 Module-scoped fixture that boots xen-image-minimal once for all tests.
224
225 Skips if pexpect is not available, image is not found, or boot fails.
226 """
227 if not PEXPECT_AVAILABLE:
228 pytest.skip("pexpect not installed. Run: pip install pexpect")
229
230 # Check that the .wic image exists
231 deploy_dir = build_dir / "tmp" / "deploy" / "images" / machine
232 wic_files = list(deploy_dir.glob("xen-image-minimal-*.rootfs.wic"))
233 if not wic_files:
234 pytest.skip(f"xen-image-minimal .wic image not found in {deploy_dir}")
235
236 timeout = request.config.getoption("--boot-timeout")
237 use_kvm = not request.config.getoption("--no-kvm")
238
239 runner = XenRunner(poky_dir, build_dir, machine,
240 use_kvm=use_kvm, timeout=timeout)
241
242 try:
243 runner.start()
244 yield runner
245 except RuntimeError as e:
246 pytest.skip(f"Failed to boot Xen image: {e}")
247 finally:
248 runner.stop()
249
250
251# ============================================================================
252# TestXenDom0Boot — Core hypervisor verification
253# ============================================================================
254
255@pytest.mark.boot
256class TestXenDom0Boot:
257 """Core Xen hypervisor verification after booting xen-image-minimal."""
258
259 def test_dom0_reaches_prompt(self, xen_session):
260 """Boot succeeds and reaches a shell prompt."""
261 assert xen_session.booted, "System failed to boot"
262 output = xen_session.run_command('uname -a')
263 assert 'Linux' in output
264
265 def test_xen_hypervisor_running(self, xen_session):
266 """xl list shows Domain-0, proving Xen hypervisor is running."""
267 output = xen_session.run_command('xl list')
268 assert 'Domain-0' in output, \
269 f"Domain-0 not found in xl list output:\n{output}"
270
271 def test_dom0_memory_reserved(self, xen_session):
272 """Domain-0 memory is capped (not consuming all RAM)."""
273 output = xen_session.run_command('xl list')
274 # Parse xl list output for Domain-0 line
275 # Format: Name ID Mem VCPUs State Time(s)
276 for line in output.splitlines():
277 if 'Domain-0' in line:
278 parts = line.split()
279 # Mem is the 3rd column (index 2)
280 if len(parts) >= 3:
281 try:
282 mem_mb = int(parts[2])
283 assert mem_mb <= 512, \
284 f"Domain-0 memory {mem_mb}MB exceeds 512MB cap"
285 except ValueError:
286 pass # Non-numeric column, skip
287 break
288
289 def test_xen_dmesg(self, xen_session):
290 """Kernel dmesg contains Xen initialization messages."""
291 output = xen_session.run_command('dmesg | grep -i xen | head -10')
292 assert output, "No Xen messages found in dmesg"
293 # Should see Xen-related init messages
294 xen_found = any(
295 kw in output.lower()
296 for kw in ['xen', 'hypervisor', 'xenbus']
297 )
298 assert xen_found, \
299 f"No Xen keywords in dmesg output:\n{output}"
300
301
302# ============================================================================
303# TestXenGuestBundleRuntime — Guest autostart verification
304# ============================================================================
305
306@pytest.mark.boot
307class TestXenGuestBundleRuntime:
308 """Verify bundled Xen guests auto-start in Dom0."""
309
310 def test_bundled_guests_visible(self, xen_session):
311 """xl list shows more than just Domain-0 (bundled guests running)."""
312 output = xen_session.run_command('xl list')
313 lines = [l for l in output.splitlines()
314 if l.strip() and not l.startswith('Name')]
315 if len(lines) <= 1:
316 pytest.skip("No bundled guests detected (only Domain-0 in xl list)")
317 # At least one guest beyond Domain-0
318 guest_count = len(lines) - 1 # subtract Domain-0
319 assert guest_count >= 1, \
320 f"Expected bundled guests, only found Domain-0:\n{output}"
321
322 def test_xendomains_service(self, xen_session):
323 """xendomains systemd service is active (manages guest autostart)."""
324 output = xen_session.run_command(
325 'systemctl is-active xendomains 2>/dev/null || echo INACTIVE')
326 if 'INACTIVE' in output or 'inactive' in output:
327 pytest.skip("xendomains service not installed or inactive")
328 assert 'active' in output.lower(), \
329 f"xendomains not active: {output}"
330
331
332# Minimum free memory (MB) needed to create a new Xen domain
333_XEN_GUEST_MIN_FREE_MB = 256
334
335
336def _check_xen_free_memory(xen_session, min_mb=_XEN_GUEST_MIN_FREE_MB):
337 """Check Xen free memory, skip test if insufficient for a new domain."""
338 output = xen_session.run_command(
339 'xl info 2>&1 | grep free_memory')
340 # Format: "free_memory : 240"
341 match = re.search(r'free_memory\s*:\s*(\d+)', output)
342 if match:
343 free_mb = int(match.group(1))
344 if free_mb < min_mb:
345 xl_list = xen_session.run_command('xl list 2>&1')
346 pytest.skip(
347 f"Insufficient Xen free memory for new domain "
348 f"({free_mb} MB free, need {min_mb} MB)\n"
349 f"xl list:\n{xl_list}")
350
351
352# ============================================================================
353# TestXenVxnStandalone — vxn on Dom0
354# ============================================================================
355
356@pytest.mark.boot
357@pytest.mark.network
358class TestXenVxnStandalone:
359 """Test vxn (Docker CLI for Xen) on Dom0. Requires network access."""
360
361 def test_vxn_available(self, xen_session):
362 """vxn binary is installed in Dom0."""
363 output = xen_session.run_command('which vxn 2>/dev/null || echo NOT_FOUND')
364 if 'NOT_FOUND' in output:
365 pytest.skip("vxn not installed in image")
366 assert '/vxn' in output
367
368 def test_vxn_run_hello(self, xen_session):
369 """vxn run --rm alpine echo hello produces 'hello'."""
370 check = xen_session.run_command('which vxn 2>/dev/null || echo NOT_FOUND')
371 if 'NOT_FOUND' in check:
372 pytest.skip("vxn not installed in image")
373
374 _check_xen_free_memory(xen_session)
375
376 output = xen_session.run_command(
377 'vxn run --rm alpine echo hello 2>&1', timeout=120)
378 assert 'hello' in output, \
379 f"Expected 'hello' in vxn output:\n{output}"
380
381
382# ============================================================================
383# TestXenContainerd — containerd + vctr on Dom0
384# ============================================================================
385
386@pytest.mark.boot
387@pytest.mark.network
388class TestXenContainerd:
389 """Test containerd and vctr on Xen Dom0. Requires network access."""
390
391 def test_containerd_running(self, xen_session):
392 """containerd systemd service is active."""
393 output = xen_session.run_command(
394 'systemctl is-active containerd 2>/dev/null || echo INACTIVE')
395 if 'INACTIVE' in output or 'inactive' in output:
396 pytest.skip("containerd not installed or inactive")
397 assert 'active' in output.lower(), \
398 f"containerd not active: {output}"
399
400 def test_ctr_pull_and_vctr_run(self, xen_session):
401 """Pull alpine via ctr and run hello-world via vctr."""
402 svc = xen_session.run_command(
403 'systemctl is-active containerd 2>/dev/null || echo INACTIVE')
404 if 'INACTIVE' in svc or 'inactive' in svc:
405 pytest.skip("containerd not installed or inactive")
406
407 check = xen_session.run_command(
408 'which vctr 2>/dev/null || echo NOT_FOUND')
409 if 'NOT_FOUND' in check:
410 pytest.skip("vctr not installed in image")
411
412 _check_xen_free_memory(xen_session)
413
414 # Pull alpine image
415 xen_session.run_command(
416 'ctr image pull docker.io/library/alpine:latest 2>&1', timeout=120)
417
418 # Run hello via vctr
419 output = xen_session.run_command(
420 'vctr run --rm docker.io/library/alpine:latest echo hello 2>&1',
421 timeout=120)
422 assert 'hello' in output, \
423 f"Expected 'hello' in vctr output:\n{output}"