summaryrefslogtreecommitdiffstats
path: root/tests/conftest.py
diff options
context:
space:
mode:
authorBruce Ashfield <bruce.ashfield@gmail.com>2026-01-01 17:15:29 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2026-02-09 03:32:52 +0000
commit1165c61f5ab8ada644c7def03e991890c4d380ca (patch)
tree1cd1c1d58039afad5a1d24b5c10d8030237e2d7c /tests/conftest.py
parentc32e1081c81ba27f0d5a21a1885601f04d329d21 (diff)
downloadmeta-virtualization-1165c61f5ab8ada644c7def03e991890c4d380ca.tar.gz
tests: add pytest framework for vdkr and vpdmn
Add pytest-based test suite for testing vdkr and vpdmn CLI tools. Tests use a separate state directory (~/.vdkr-test/) to avoid interfering with production images. Test files: - conftest.py: Pytest fixtures for VdkrRunner and VpdmnRunner - test_vdkr.py: Docker CLI tests (images, vimport, vrun, volumes, etc.) - test_vpdmn.py: Podman CLI tests (mirrors vdkr test coverage) - memres-test.sh: Helper script for running tests with memres - pytest.ini: Pytest configuration and markers Test categories: - Basic operations: images, info, version - Import/export: vimport, load, save - Container execution: vrun, run, exec - Storage management: system df, vstorage - Memory resident mode: memres/vmemres start/stop/status Running tests: pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vcontainer-standalone pytest tests/test_vpdmn.py -v --vdkr-dir /tmp/vcontainer-standalone Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
Diffstat (limited to 'tests/conftest.py')
-rw-r--r--tests/conftest.py609
1 files changed, 609 insertions, 0 deletions
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..39d92322
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,609 @@
1# SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield
2#
3# SPDX-License-Identifier: MIT
4"""
5Pytest configuration and fixtures for vdkr, vpdmn and container-cross-install testing.
6
7Usage:
8 # Run all tests (default path: /tmp/vcontainer)
9 pytest tests/ --vdkr-dir /tmp/vcontainer
10
11 # Run vdkr tests only
12 pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vcontainer
13
14 # Run vpdmn tests only
15 pytest tests/test_vpdmn.py -v --vdkr-dir /tmp/vcontainer
16
17 # Run with memres pre-started (faster)
18 ./tests/memres-test.sh start --vdkr-dir /tmp/vcontainer
19 pytest tests/test_vdkr.py --vdkr-dir /tmp/vcontainer --skip-destructive
20
21 # Run specific test
22 pytest tests/test_vdkr.py::TestMemresBasic -v --vdkr-dir /tmp/vcontainer
23
24Requirements:
25 pip install pytest
26
27Environment:
28 VDKR_STANDALONE_DIR: Path to extracted vdkr/vpdmn standalone tarball
29 VDKR_ARCH: Architecture to test (x86_64 or aarch64), default: x86_64
30
31Notes:
32 - Tests use separate state directories (~/.vdkr-test/, ~/.vpdmn-test/) to avoid
33 interfering with user's images in ~/.vdkr/ and ~/.vpdmn/.
34 - If memres is already running, tests reuse it and don't stop it at the end.
35 - Tests pull required images (alpine) automatically if not present.
36"""
37
38import os
39import subprocess
40import shutil
41import tempfile
42import signal
43import atexit
44import pytest
45from pathlib import Path
46
47
48# Test state directories - separate from user's ~/.vdkr/ and ~/.vpdmn/
49TEST_STATE_BASE = os.path.expanduser("~/.vdkr-test")
50VPDMN_TEST_STATE_BASE = os.path.expanduser("~/.vpdmn-test")
51
52# Track test memres PIDs for cleanup
53_test_memres_pids = set()
54
55
56def _cleanup_test_memres():
57 """
58 Clean up any test memres processes that may have been left running.
59 Called on exit (atexit) and signal handlers.
60 """
61 for state_base in [TEST_STATE_BASE, VPDMN_TEST_STATE_BASE]:
62 for arch_dir in Path(state_base).glob("*"):
63 pid_file = arch_dir / "daemon.pid"
64 if pid_file.exists():
65 try:
66 pid = int(pid_file.read_text().strip())
67 # Check if process is still running
68 if Path(f"/proc/{pid}").exists():
69 os.kill(pid, signal.SIGTERM)
70 # Give it a moment to clean up
71 import time
72 time.sleep(0.5)
73 # Force kill if still running
74 if Path(f"/proc/{pid}").exists():
75 os.kill(pid, signal.SIGKILL)
76 except (ValueError, ProcessLookupError, PermissionError):
77 pass
78 # Remove stale PID file
79 try:
80 pid_file.unlink()
81 except OSError:
82 pass
83
84
85def _signal_handler(signum, frame):
86 """Handle SIGINT/SIGTERM by cleaning up test memres before exit."""
87 _cleanup_test_memres()
88 # Re-raise the signal to trigger default behavior
89 signal.signal(signum, signal.SIG_DFL)
90 os.kill(os.getpid(), signum)
91
92
93# Register cleanup handlers
94atexit.register(_cleanup_test_memres)
95signal.signal(signal.SIGINT, _signal_handler)
96signal.signal(signal.SIGTERM, _signal_handler)
97
98
99def pytest_addoption(parser):
100 """Add custom command line options."""
101 # vdkr/vpdmn options
102 parser.addoption(
103 "--vdkr-dir",
104 action="store",
105 default=os.environ.get("VDKR_STANDALONE_DIR", "/tmp/vcontainer"),
106 help="Path to vcontainer standalone directory",
107 )
108 parser.addoption(
109 "--arch",
110 action="store",
111 default=os.environ.get("VDKR_ARCH", "x86_64"),
112 choices=["x86_64", "aarch64"],
113 help="Target architecture to test",
114 )
115 parser.addoption(
116 "--oci-image",
117 action="store",
118 default=os.environ.get("TEST_OCI_IMAGE"),
119 help="Path to OCI image for import tests",
120 )
121 parser.addoption(
122 "--skip-destructive",
123 action="store_true",
124 default=False,
125 help="Skip tests that stop memres or clean state (useful when reusing test memres)",
126 )
127 # container-cross-install options
128 parser.addoption(
129 "--poky-dir",
130 action="store",
131 default=os.environ.get("POKY_DIR", "/opt/bruce/poky"),
132 help="Path to poky directory",
133 )
134 parser.addoption(
135 "--build-dir",
136 action="store",
137 default=os.environ.get("BUILD_DIR"),
138 help="Path to build directory",
139 )
140 parser.addoption(
141 "--machine",
142 action="store",
143 default=os.environ.get("MACHINE", "qemux86-64"),
144 help="Target machine",
145 )
146 parser.addoption(
147 "--image",
148 action="store",
149 default=os.environ.get("TEST_IMAGE", "container-image-host"),
150 help="Image to boot for container verification tests",
151 )
152 parser.addoption(
153 "--image-fstype",
154 action="store",
155 default=os.environ.get("TEST_IMAGE_FSTYPE", "ext4"),
156 help="Image filesystem type (default: ext4)",
157 )
158 parser.addoption(
159 "--boot-timeout",
160 action="store",
161 type=int,
162 default=120,
163 help="Timeout in seconds for image boot (default: 120)",
164 )
165 parser.addoption(
166 "--no-kvm",
167 action="store_true",
168 default=False,
169 help="Disable KVM acceleration",
170 )
171 parser.addoption(
172 "--fail-stale",
173 action="store_true",
174 default=False,
175 help="Fail if rootfs is stale (OCI containers or bbclass newer than rootfs)",
176 )
177 parser.addoption(
178 "--max-age",
179 action="store",
180 type=float,
181 default=24.0,
182 help="Max rootfs age in hours before warning (default: 24)",
183 )
184
185
186@pytest.fixture(scope="session")
187def vdkr_dir(request):
188 """Path to vdkr standalone directory."""
189 path = Path(request.config.getoption("--vdkr-dir"))
190 if not path.exists():
191 pytest.skip(f"vdkr standalone directory not found: {path}")
192 return path
193
194
195@pytest.fixture(scope="session")
196def arch(request):
197 """Target architecture."""
198 return request.config.getoption("--arch")
199
200
201@pytest.fixture(scope="session")
202def vdkr_bin(vdkr_dir, arch):
203 """Path to vdkr binary for the target architecture.
204
205 Tries arch-specific symlink first (vdkr-x86_64), then main vdkr binary.
206 """
207 # Try arch-specific symlink first
208 binary = vdkr_dir / f"vdkr-{arch}"
209 if binary.exists():
210 return binary
211
212 # Fall back to main vdkr binary
213 binary = vdkr_dir / "vdkr"
214 if binary.exists():
215 return binary
216
217 pytest.skip(f"vdkr binary not found: {vdkr_dir}/vdkr or {vdkr_dir}/vdkr-{arch}")
218
219
220@pytest.fixture(scope="session")
221def test_state_dir(arch):
222 """Test-specific state directory to avoid interfering with user's state."""
223 state_dir = Path(TEST_STATE_BASE) / arch
224 state_dir.mkdir(parents=True, exist_ok=True)
225 return state_dir
226
227
228@pytest.fixture(scope="session")
229def vdkr_env(vdkr_dir):
230 """Environment variables for running vdkr."""
231 env = os.environ.copy()
232
233 # Source init-env.sh equivalent
234 # Ensure vdkr_dir is a string for PATH concatenation
235 vdkr_path = str(vdkr_dir)
236
237 # Support both old layout (qemu/, lib/) and new SDK layout (sysroots/)
238 sysroot_dir = vdkr_dir / "sysroots" / "x86_64-pokysdk-linux"
239 if sysroot_dir.exists():
240 # New SDK layout: sysroots/x86_64-pokysdk-linux/usr/bin/
241 env["PATH"] = f"{vdkr_path}:{sysroot_dir}/usr/bin:/usr/bin:/bin:{env.get('PATH', '')}"
242 # No LD_LIBRARY_PATH needed - SDK uses proper RPATH
243 else:
244 # Old layout: qemu/, lib/
245 env["PATH"] = f"{vdkr_path}:{vdkr_path}/qemu:/usr/bin:/bin:{env.get('PATH', '')}"
246 env["LD_LIBRARY_PATH"] = f"{vdkr_path}/lib:{env.get('LD_LIBRARY_PATH', '')}"
247
248 return env
249
250
251@pytest.fixture(scope="session")
252def oci_image(request):
253 """Path to test OCI image, if available."""
254 path = request.config.getoption("--oci-image")
255 if path:
256 path = Path(path)
257 if not path.exists():
258 pytest.skip(f"OCI image not found: {path}")
259 return path
260 return None
261
262
263class VdkrRunner:
264 """Helper class for running vdkr commands."""
265
266 def __init__(self, binary: Path, env: dict, arch: str, state_dir: Path):
267 self.binary = binary
268 self.env = env
269 self.arch = arch
270 self.state_dir = state_dir
271 self._user_memres_was_running = None
272 # Check if we're using main vdkr (needs --arch) vs arch-specific symlink
273 self._needs_arch_flag = binary.name == "vdkr"
274
275 def run(self, *args, timeout=120, check=True, capture_output=True):
276 """Run a vdkr command with test state directory."""
277 cmd = [str(self.binary)]
278 if self._needs_arch_flag:
279 cmd.extend(["--arch", self.arch])
280 cmd.extend(["--state-dir", str(self.state_dir)])
281 cmd.extend(list(args))
282 result = subprocess.run(
283 cmd,
284 env=self.env,
285 timeout=timeout,
286 check=False, # Don't raise immediately, check manually for better error messages
287 capture_output=capture_output,
288 text=True,
289 )
290 if check and result.returncode != 0:
291 error_msg = f"Command failed: {' '.join(cmd)}\n"
292 error_msg += f"Exit code: {result.returncode}\n"
293 if result.stdout:
294 error_msg += f"stdout: {result.stdout}\n"
295 if result.stderr:
296 error_msg += f"stderr: {result.stderr}\n"
297 # Print error so it's visible in test output
298 print(error_msg)
299 raise AssertionError(error_msg)
300 return result
301
302 def memres_start(self, timeout=120):
303 """Start memory resident mode."""
304 return self.run("memres", "start", timeout=timeout)
305
306 def memres_stop(self, timeout=30):
307 """Stop memory resident mode."""
308 return self.run("memres", "stop", timeout=timeout, check=False)
309
310 def memres_status(self):
311 """Check memory resident status."""
312 return self.run("memres", "status", check=False)
313
314 def is_memres_running(self):
315 """Check if memres is running (in test state dir)."""
316 result = self.memres_status()
317 return result.returncode == 0 and "running" in result.stdout.lower()
318
319 def ensure_memres(self, timeout=180):
320 """Ensure memres is running, starting it if needed."""
321 if not self.is_memres_running():
322 result = self.memres_start(timeout=timeout)
323 if result.returncode != 0:
324 raise RuntimeError(f"Failed to start memres: {result.stderr}")
325
326 def is_user_memres_running(self):
327 """Check if user's memres is running (in default ~/.vdkr/)."""
328 # Check without --state-dir to see user's memres
329 cmd = [str(self.binary)]
330 if self._needs_arch_flag:
331 cmd.extend(["--arch", self.arch])
332 cmd.extend(["memres", "status"])
333 result = subprocess.run(
334 cmd, env=self.env, capture_output=True, text=True, timeout=10
335 )
336 return result.returncode == 0 and "running" in result.stdout.lower()
337
338 def images(self, timeout=120):
339 """List images."""
340 return self.run("images", timeout=timeout)
341
342 def clean(self):
343 """Clean state."""
344 return self.run("clean", check=False)
345
346 def vimport(self, path, name, timeout=120):
347 """Import an OCI image."""
348 return self.run("vimport", str(path), name, timeout=timeout)
349
350 def pull(self, image, timeout=180):
351 """Pull an image from registry."""
352 return self.run("pull", image, timeout=timeout)
353
354 def rmi(self, image, timeout=60):
355 """Remove an image."""
356 return self.run("rmi", image, timeout=timeout, check=False)
357
358 def vrun(self, image, *cmd, timeout=120):
359 """Run a command in a container."""
360 return self.run("vrun", image, *cmd, timeout=timeout)
361
362 def inspect(self, target, timeout=60):
363 """Inspect an image or container."""
364 return self.run("inspect", target, timeout=timeout)
365
366 def save(self, output_file, image, timeout=120):
367 """Save an image to a tar file."""
368 return self.run("save", "-o", str(output_file), image, timeout=timeout)
369
370 def load(self, input_file, timeout=120):
371 """Load an image from a tar file."""
372 return self.run("load", "-i", str(input_file), timeout=timeout)
373
374 def has_image(self, image_name):
375 """Check if an image exists."""
376 self.ensure_memres()
377 result = self.images()
378 return image_name.split(":")[0] in result.stdout
379
380 def ensure_alpine(self, timeout=300):
381 """Ensure alpine:latest is available, pulling if necessary."""
382 # Ensure memres is running first (in case a previous test stopped it)
383 self.ensure_memres()
384 if not self.has_image("alpine"):
385 self.pull("alpine:latest", timeout=timeout)
386
387
388@pytest.fixture(scope="session")
389def vdkr(vdkr_bin, vdkr_env, arch, test_state_dir):
390 """VdkrRunner instance for running vdkr commands."""
391 return VdkrRunner(vdkr_bin, vdkr_env, arch, test_state_dir)
392
393
394@pytest.fixture(scope="session")
395def memres_session(vdkr):
396 """
397 Session-scoped fixture that ensures memres is running for tests.
398 Uses separate test state directory (~/.vdkr-test/).
399
400 Note: TestMemresBasic tests may stop/restart memres during the session.
401 Tests using this fixture should call ensure_memres() or ensure_alpine()
402 to guarantee memres is running before executing commands.
403 """
404 # Check if memres was already running at session start
405 was_running_at_start = vdkr.is_memres_running()
406
407 # Ensure memres is running
408 vdkr.ensure_memres()
409
410 yield vdkr
411
412 # Only stop memres if it wasn't running when we started
413 if not was_running_at_start:
414 vdkr.memres_stop()
415
416
417@pytest.fixture
418def temp_dir():
419 """Create a temporary directory for test files."""
420 tmpdir = tempfile.mkdtemp(prefix="vdkr-test-")
421 yield Path(tmpdir)
422 shutil.rmtree(tmpdir, ignore_errors=True)
423
424
425# Markers
426def pytest_configure(config):
427 """Register custom markers."""
428 config.addinivalue_line(
429 "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')"
430 )
431 config.addinivalue_line(
432 "markers", "memres: marks tests that require memory resident mode"
433 )
434 config.addinivalue_line(
435 "markers", "network: marks tests that require network access"
436 )
437
438
439# ============================================================================
440# vpdmn (Podman) fixtures
441# ============================================================================
442
443@pytest.fixture(scope="session")
444def vpdmn_bin(vdkr_dir, arch):
445 """Path to vpdmn binary for the target architecture.
446
447 Tries arch-specific symlink first (vpdmn-x86_64), then main vpdmn binary.
448 """
449 # Try arch-specific symlink first
450 binary = vdkr_dir / f"vpdmn-{arch}"
451 if binary.exists():
452 return binary
453
454 # Fall back to main vpdmn binary
455 binary = vdkr_dir / "vpdmn"
456 if binary.exists():
457 return binary
458
459 pytest.skip(f"vpdmn binary not found: {vdkr_dir}/vpdmn or {vdkr_dir}/vpdmn-{arch}")
460
461
462@pytest.fixture(scope="session")
463def vpdmn_test_state_dir(arch):
464 """Test-specific state directory for vpdmn to avoid interfering with user's state."""
465 state_dir = Path(VPDMN_TEST_STATE_BASE) / arch
466 state_dir.mkdir(parents=True, exist_ok=True)
467 return state_dir
468
469
470class VpdmnRunner:
471 """Helper class for running vpdmn commands."""
472
473 def __init__(self, binary: Path, env: dict, arch: str, state_dir: Path):
474 self.binary = binary
475 self.env = env
476 self.arch = arch
477 self.state_dir = state_dir
478 self._user_memres_was_running = None
479 # Check if we're using main vpdmn (needs --arch) vs arch-specific symlink
480 self._needs_arch_flag = binary.name == "vpdmn"
481
482 def run(self, *args, timeout=120, check=True, capture_output=True):
483 """Run a vpdmn command with test state directory."""
484 cmd = [str(self.binary)]
485 if self._needs_arch_flag:
486 cmd.extend(["--arch", self.arch])
487 cmd.extend(["--state-dir", str(self.state_dir)])
488 cmd.extend(list(args))
489 result = subprocess.run(
490 cmd,
491 env=self.env,
492 timeout=timeout,
493 check=False, # Don't raise immediately, check manually for better error messages
494 capture_output=capture_output,
495 text=True,
496 )
497 if check and result.returncode != 0:
498 error_msg = f"Command failed: {' '.join(cmd)}\n"
499 error_msg += f"Exit code: {result.returncode}\n"
500 if result.stdout:
501 error_msg += f"stdout: {result.stdout}\n"
502 if result.stderr:
503 error_msg += f"stderr: {result.stderr}\n"
504 # Print error so it's visible in test output
505 print(error_msg)
506 raise AssertionError(error_msg)
507 return result
508
509 def memres_start(self, timeout=120):
510 """Start memory resident mode."""
511 return self.run("memres", "start", timeout=timeout)
512
513 def memres_stop(self, timeout=30):
514 """Stop memory resident mode."""
515 return self.run("memres", "stop", timeout=timeout, check=False)
516
517 def memres_status(self):
518 """Check memory resident status."""
519 return self.run("memres", "status", check=False)
520
521 def is_memres_running(self):
522 """Check if memres is running (in test state dir)."""
523 result = self.memres_status()
524 return result.returncode == 0 and "running" in result.stdout.lower()
525
526 def ensure_memres(self, timeout=180):
527 """Ensure memres is running, starting it if needed."""
528 if not self.is_memres_running():
529 result = self.memres_start(timeout=timeout)
530 if result.returncode != 0:
531 raise RuntimeError(f"Failed to start memres: {result.stderr}")
532
533 def images(self, timeout=120):
534 """List images."""
535 return self.run("images", timeout=timeout)
536
537 def clean(self):
538 """Clean state."""
539 return self.run("clean", check=False)
540
541 def vimport(self, path, name, timeout=120):
542 """Import an OCI image."""
543 return self.run("vimport", str(path), name, timeout=timeout)
544
545 def pull(self, image, timeout=180):
546 """Pull an image from registry."""
547 return self.run("pull", image, timeout=timeout)
548
549 def rmi(self, image, timeout=60):
550 """Remove an image."""
551 return self.run("rmi", image, timeout=timeout, check=False)
552
553 def vrun(self, image, *cmd, timeout=120):
554 """Run a command in a container."""
555 return self.run("vrun", image, *cmd, timeout=timeout)
556
557 def inspect(self, target, timeout=60):
558 """Inspect an image or container."""
559 return self.run("inspect", target, timeout=timeout)
560
561 def save(self, output_file, image, timeout=120):
562 """Save an image to a tar file."""
563 return self.run("save", "-o", str(output_file), image, timeout=timeout)
564
565 def load(self, input_file, timeout=120):
566 """Load an image from a tar file."""
567 return self.run("load", "-i", str(input_file), timeout=timeout)
568
569 def has_image(self, image_name):
570 """Check if an image exists."""
571 self.ensure_memres()
572 result = self.images()
573 return image_name.split(":")[0] in result.stdout
574
575 def ensure_alpine(self, timeout=300):
576 """Ensure alpine:latest is available, pulling if necessary."""
577 # Ensure memres is running first (in case a previous test stopped it)
578 self.ensure_memres()
579 if not self.has_image("alpine"):
580 self.pull("alpine:latest", timeout=timeout)
581
582
583@pytest.fixture(scope="session")
584def vpdmn(vpdmn_bin, vdkr_env, arch, vpdmn_test_state_dir):
585 """VpdmnRunner instance for running vpdmn commands."""
586 return VpdmnRunner(vpdmn_bin, vdkr_env, arch, vpdmn_test_state_dir)
587
588
589@pytest.fixture(scope="session")
590def vpdmn_memres_session(vpdmn):
591 """
592 Session-scoped fixture that ensures memres is running for vpdmn tests.
593 Uses separate test state directory (~/.vpdmn-test/).
594
595 Note: TestMemresBasic tests may stop/restart memres during the session.
596 Tests using this fixture should call ensure_memres() or ensure_alpine()
597 to guarantee memres is running before executing commands.
598 """
599 # Check if memres was already running at session start
600 was_running_at_start = vpdmn.is_memres_running()
601
602 # Ensure memres is running
603 vpdmn.ensure_memres()
604
605 yield vpdmn
606
607 # Only stop memres if it wasn't running when we started
608 if not was_running_at_start:
609 vpdmn.memres_stop()