diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-01 17:15:29 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-02-09 03:32:52 +0000 |
| commit | 1165c61f5ab8ada644c7def03e991890c4d380ca (patch) | |
| tree | 1cd1c1d58039afad5a1d24b5c10d8030237e2d7c /tests/test_vdkr.py | |
| parent | c32e1081c81ba27f0d5a21a1885601f04d329d21 (diff) | |
| download | meta-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/test_vdkr.py')
| -rw-r--r-- | tests/test_vdkr.py | 726 |
1 files changed, 726 insertions, 0 deletions
diff --git a/tests/test_vdkr.py b/tests/test_vdkr.py new file mode 100644 index 00000000..07715a66 --- /dev/null +++ b/tests/test_vdkr.py | |||
| @@ -0,0 +1,726 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for vdkr - Docker CLI for cross-architecture emulation. | ||
| 6 | |||
| 7 | These tests verify vdkr functionality including: | ||
| 8 | - Memory resident mode (memres) | ||
| 9 | - Image management (images, pull, import, save, load) | ||
| 10 | - Container execution (vrun) | ||
| 11 | - System commands (system df, system prune) | ||
| 12 | - Storage management (vstorage list, path, df, clean) | ||
| 13 | |||
| 14 | Tests use a separate state directory (~/.vdkr-test/) to avoid | ||
| 15 | interfering with user's images in ~/.vdkr/. | ||
| 16 | |||
| 17 | Run with: | ||
| 18 | pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vdkr-standalone | ||
| 19 | |||
| 20 | Run with memres already started (faster): | ||
| 21 | ./tests/memres-test.sh start --vdkr-dir /tmp/vdkr-standalone | ||
| 22 | pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vdkr-standalone --skip-destructive | ||
| 23 | |||
| 24 | Run with OCI image for import tests: | ||
| 25 | pytest tests/test_vdkr.py -v --vdkr-dir /tmp/vdkr-standalone --oci-image /path/to/container-oci | ||
| 26 | """ | ||
| 27 | |||
| 28 | import pytest | ||
| 29 | import json | ||
| 30 | import os | ||
| 31 | |||
| 32 | |||
| 33 | class TestMemresBasic: | ||
| 34 | """Test memory resident mode basic operations. | ||
| 35 | |||
| 36 | These tests use a separate state directory (~/.vdkr-test/) so they | ||
| 37 | don't interfere with user's memres in ~/.vdkr/. | ||
| 38 | """ | ||
| 39 | |||
| 40 | def test_memres_start(self, vdkr): | ||
| 41 | """Test starting memory resident mode.""" | ||
| 42 | # Stop first if running | ||
| 43 | vdkr.memres_stop() | ||
| 44 | |||
| 45 | result = vdkr.memres_start(timeout=180) | ||
| 46 | assert result.returncode == 0, f"memres start failed: {result.stderr}" | ||
| 47 | |||
| 48 | def test_memres_status(self, vdkr): | ||
| 49 | """Test checking memory resident status.""" | ||
| 50 | if not vdkr.is_memres_running(): | ||
| 51 | vdkr.memres_start(timeout=180) | ||
| 52 | |||
| 53 | result = vdkr.memres_status() | ||
| 54 | assert result.returncode == 0 | ||
| 55 | assert "running" in result.stdout.lower() or "started" in result.stdout.lower() | ||
| 56 | |||
| 57 | def test_memres_stop(self, vdkr): | ||
| 58 | """Test stopping memory resident mode.""" | ||
| 59 | # Ensure running first | ||
| 60 | if not vdkr.is_memres_running(): | ||
| 61 | vdkr.memres_start(timeout=180) | ||
| 62 | |||
| 63 | result = vdkr.memres_stop() | ||
| 64 | assert result.returncode == 0 | ||
| 65 | |||
| 66 | # Verify stopped | ||
| 67 | status = vdkr.memres_status() | ||
| 68 | assert status.returncode != 0 or "not running" in status.stdout.lower() | ||
| 69 | |||
| 70 | def test_memres_restart(self, vdkr): | ||
| 71 | """Test restarting memory resident mode.""" | ||
| 72 | result = vdkr.run("memres", "restart", timeout=180) | ||
| 73 | assert result.returncode == 0 | ||
| 74 | |||
| 75 | # Verify running | ||
| 76 | assert vdkr.is_memres_running() | ||
| 77 | |||
| 78 | |||
| 79 | class TestImages: | ||
| 80 | """Test image management commands.""" | ||
| 81 | |||
| 82 | def test_images_list(self, memres_session): | ||
| 83 | """Test images command.""" | ||
| 84 | vdkr = memres_session | ||
| 85 | vdkr.ensure_memres() | ||
| 86 | result = vdkr.images() | ||
| 87 | assert result.returncode == 0 | ||
| 88 | # Should have header line at minimum | ||
| 89 | assert "REPOSITORY" in result.stdout or "IMAGE" in result.stdout | ||
| 90 | |||
| 91 | @pytest.mark.network | ||
| 92 | def test_pull_alpine(self, memres_session): | ||
| 93 | """Test pulling alpine image from registry.""" | ||
| 94 | vdkr = memres_session | ||
| 95 | vdkr.ensure_memres() | ||
| 96 | |||
| 97 | # Pull alpine (small image) | ||
| 98 | result = vdkr.pull("alpine:latest", timeout=300) | ||
| 99 | assert result.returncode == 0 | ||
| 100 | |||
| 101 | # Verify it appears in images | ||
| 102 | images = vdkr.images() | ||
| 103 | assert "alpine" in images.stdout | ||
| 104 | |||
| 105 | def test_rmi(self, memres_session): | ||
| 106 | """Test removing an image.""" | ||
| 107 | vdkr = memres_session | ||
| 108 | |||
| 109 | # Ensure we have alpine to test with | ||
| 110 | vdkr.ensure_alpine() | ||
| 111 | |||
| 112 | # Force remove to handle containers using the image | ||
| 113 | result = vdkr.run("rmi", "-f", "alpine:latest", check=False) | ||
| 114 | assert result.returncode == 0 | ||
| 115 | |||
| 116 | |||
| 117 | class TestVimport: | ||
| 118 | """Test vimport command for OCI image import.""" | ||
| 119 | |||
| 120 | def test_vimport_oci(self, memres_session, oci_image): | ||
| 121 | """Test importing an OCI directory.""" | ||
| 122 | if oci_image is None: | ||
| 123 | pytest.skip("No OCI image provided (use --oci-image)") | ||
| 124 | |||
| 125 | vdkr = memres_session | ||
| 126 | vdkr.ensure_memres() | ||
| 127 | result = vdkr.vimport(oci_image, "test-import:latest", timeout=180) | ||
| 128 | assert result.returncode == 0 | ||
| 129 | |||
| 130 | # Verify it appears in images | ||
| 131 | images = vdkr.images() | ||
| 132 | assert "test-import" in images.stdout | ||
| 133 | |||
| 134 | |||
| 135 | class TestSaveLoad: | ||
| 136 | """Test save and load commands.""" | ||
| 137 | |||
| 138 | def test_save_and_load(self, memres_session, temp_dir): | ||
| 139 | """Test saving and loading an image.""" | ||
| 140 | vdkr = memres_session | ||
| 141 | |||
| 142 | # Ensure we have alpine | ||
| 143 | vdkr.ensure_alpine() | ||
| 144 | |||
| 145 | tar_path = temp_dir / "test-save.tar" | ||
| 146 | |||
| 147 | # Save | ||
| 148 | result = vdkr.save(tar_path, "alpine:latest", timeout=180) | ||
| 149 | assert result.returncode == 0 | ||
| 150 | assert tar_path.exists() | ||
| 151 | assert tar_path.stat().st_size > 0 | ||
| 152 | |||
| 153 | # Remove the image | ||
| 154 | vdkr.run("rmi", "-f", "alpine:latest", check=False) | ||
| 155 | |||
| 156 | # Load | ||
| 157 | result = vdkr.load(tar_path, timeout=180) | ||
| 158 | assert result.returncode == 0 | ||
| 159 | |||
| 160 | # Verify it's back | ||
| 161 | images = vdkr.images() | ||
| 162 | assert "alpine" in images.stdout | ||
| 163 | |||
| 164 | |||
| 165 | class TestVrun: | ||
| 166 | """Test vrun command for container execution.""" | ||
| 167 | |||
| 168 | def test_vrun_echo(self, memres_session): | ||
| 169 | """Test running echo command in a container.""" | ||
| 170 | vdkr = memres_session | ||
| 171 | vdkr.ensure_alpine() | ||
| 172 | |||
| 173 | result = vdkr.vrun("alpine:latest", "/bin/echo", "hello", "world") | ||
| 174 | assert result.returncode == 0 | ||
| 175 | assert "hello world" in result.stdout | ||
| 176 | |||
| 177 | def test_vrun_uname(self, memres_session, arch): | ||
| 178 | """Test running uname to verify architecture.""" | ||
| 179 | vdkr = memres_session | ||
| 180 | vdkr.ensure_alpine() | ||
| 181 | |||
| 182 | result = vdkr.vrun("alpine:latest", "/bin/uname", "-m") | ||
| 183 | assert result.returncode == 0 | ||
| 184 | |||
| 185 | # Check architecture matches | ||
| 186 | expected_arch = "x86_64" if arch == "x86_64" else "aarch64" | ||
| 187 | assert expected_arch in result.stdout | ||
| 188 | |||
| 189 | def test_vrun_exit_code(self, memres_session): | ||
| 190 | """Test container command execution.""" | ||
| 191 | vdkr = memres_session | ||
| 192 | vdkr.ensure_alpine() | ||
| 193 | |||
| 194 | # Run command that exits with code 1 (false command) | ||
| 195 | result = vdkr.run("vrun", "alpine:latest", "/bin/false", | ||
| 196 | check=False, timeout=60) | ||
| 197 | # Container exit codes may or may not be propagated depending on vdkr implementation | ||
| 198 | # At minimum, verify the command ran (no crash/timeout) | ||
| 199 | # Note: exit code propagation is a future enhancement | ||
| 200 | assert result.returncode in [0, 1], f"Unexpected return code: {result.returncode}" | ||
| 201 | |||
| 202 | |||
| 203 | class TestInspect: | ||
| 204 | """Test inspect command.""" | ||
| 205 | |||
| 206 | def test_inspect_image(self, memres_session): | ||
| 207 | """Test inspecting an image.""" | ||
| 208 | vdkr = memres_session | ||
| 209 | vdkr.ensure_alpine() | ||
| 210 | |||
| 211 | result = vdkr.inspect("alpine:latest") | ||
| 212 | assert result.returncode == 0 | ||
| 213 | |||
| 214 | # Should be valid JSON | ||
| 215 | data = json.loads(result.stdout) | ||
| 216 | assert isinstance(data, list) | ||
| 217 | assert len(data) > 0 | ||
| 218 | |||
| 219 | |||
| 220 | class TestHistory: | ||
| 221 | """Test history command.""" | ||
| 222 | |||
| 223 | def test_history(self, memres_session): | ||
| 224 | """Test showing image history.""" | ||
| 225 | vdkr = memres_session | ||
| 226 | vdkr.ensure_alpine() | ||
| 227 | |||
| 228 | result = vdkr.run("history", "alpine:latest") | ||
| 229 | assert result.returncode == 0 | ||
| 230 | assert "IMAGE" in result.stdout or "CREATED" in result.stdout | ||
| 231 | |||
| 232 | |||
| 233 | class TestClean: | ||
| 234 | """Test clean command.""" | ||
| 235 | |||
| 236 | def test_clean(self, vdkr, request): | ||
| 237 | """Test cleaning state directory.""" | ||
| 238 | if request.config.getoption("--skip-destructive"): | ||
| 239 | pytest.skip("Skipped with --skip-destructive") | ||
| 240 | |||
| 241 | # Stop memres first | ||
| 242 | vdkr.memres_stop() | ||
| 243 | |||
| 244 | result = vdkr.clean() | ||
| 245 | assert result.returncode == 0 | ||
| 246 | |||
| 247 | |||
| 248 | class TestFallbackMode: | ||
| 249 | """Test fallback to regular QEMU mode when memres not running.""" | ||
| 250 | |||
| 251 | @pytest.mark.slow | ||
| 252 | def test_images_without_memres(self, vdkr, request): | ||
| 253 | """Test images command works without memres (slower).""" | ||
| 254 | if request.config.getoption("--skip-destructive"): | ||
| 255 | pytest.skip("Skipped with --skip-destructive") | ||
| 256 | |||
| 257 | # Ensure memres is stopped | ||
| 258 | vdkr.memres_stop() | ||
| 259 | |||
| 260 | # This should still work, just slower | ||
| 261 | result = vdkr.images(timeout=120) | ||
| 262 | assert result.returncode == 0 | ||
| 263 | |||
| 264 | |||
| 265 | class TestContainerLifecycle: | ||
| 266 | """Test container lifecycle commands.""" | ||
| 267 | |||
| 268 | @pytest.mark.slow | ||
| 269 | def test_run_detached_and_manage(self, memres_session): | ||
| 270 | """Test running a detached container and managing it.""" | ||
| 271 | vdkr = memres_session | ||
| 272 | vdkr.ensure_alpine() | ||
| 273 | |||
| 274 | # Run a container in detached mode | ||
| 275 | # Note: vdkr run auto-prepends "docker run", so just pass the docker run args | ||
| 276 | result = vdkr.run("run", "-d", "--name", "test-container", "alpine:latest", "sleep", "300", | ||
| 277 | timeout=60, check=False) | ||
| 278 | if result.returncode != 0: | ||
| 279 | # Show error for debugging | ||
| 280 | print(f"Failed to start detached container: {result.stderr}") | ||
| 281 | pytest.skip("Could not start detached container") | ||
| 282 | |||
| 283 | try: | ||
| 284 | # List containers | ||
| 285 | ps_result = vdkr.run("ps") | ||
| 286 | assert "test-container" in ps_result.stdout | ||
| 287 | |||
| 288 | # Stop container | ||
| 289 | stop_result = vdkr.run("stop", "test-container", timeout=30) | ||
| 290 | assert stop_result.returncode == 0 | ||
| 291 | |||
| 292 | # Remove container | ||
| 293 | rm_result = vdkr.run("rm", "test-container") | ||
| 294 | assert rm_result.returncode == 0 | ||
| 295 | |||
| 296 | finally: | ||
| 297 | # Cleanup | ||
| 298 | vdkr.run("rm", "-f", "test-container", check=False) | ||
| 299 | |||
| 300 | |||
| 301 | class TestVolumeMounts: | ||
| 302 | """Test volume mount functionality. | ||
| 303 | |||
| 304 | Volume mounts require memres to be running. | ||
| 305 | """ | ||
| 306 | |||
| 307 | def test_volume_mount_read_file(self, memres_session, temp_dir): | ||
| 308 | """Test mounting a host directory and reading a file from it.""" | ||
| 309 | vdkr = memres_session | ||
| 310 | vdkr.ensure_alpine() | ||
| 311 | |||
| 312 | # Create a test file on host | ||
| 313 | test_file = temp_dir / "testfile.txt" | ||
| 314 | test_content = "Hello from host volume!" | ||
| 315 | test_file.write_text(test_content) | ||
| 316 | |||
| 317 | # Run container with volume mount and read the file | ||
| 318 | result = vdkr.run("vrun", "-v", f"{temp_dir}:/data", "alpine:latest", | ||
| 319 | "cat", "/data/testfile.txt", timeout=60) | ||
| 320 | assert result.returncode == 0 | ||
| 321 | assert test_content in result.stdout | ||
| 322 | |||
| 323 | def test_volume_mount_write_file(self, memres_session, temp_dir): | ||
| 324 | """Test writing a file in a mounted volume.""" | ||
| 325 | vdkr = memres_session | ||
| 326 | vdkr.ensure_alpine() | ||
| 327 | |||
| 328 | # Create a script that writes to a file - avoids shell metacharacter issues | ||
| 329 | # when passing through multiple shells (host -> vdkr -> runner -> guest -> container) | ||
| 330 | # Include sync to ensure write is flushed to host via 9p/virtio-fs | ||
| 331 | script = temp_dir / "write.sh" | ||
| 332 | script.write_text("#!/bin/sh\necho 'Created in container' > /data/output.txt\nsync\n") | ||
| 333 | script.chmod(0o755) | ||
| 334 | |||
| 335 | # Run the script inside the container | ||
| 336 | result = vdkr.run("vrun", "-v", f"{temp_dir}:/data", "alpine:latest", | ||
| 337 | "/data/write.sh", timeout=60) | ||
| 338 | assert result.returncode == 0 | ||
| 339 | |||
| 340 | # Verify the file was synced back to host | ||
| 341 | output_file = temp_dir / "output.txt" | ||
| 342 | assert output_file.exists(), "Output file should be synced back to host" | ||
| 343 | assert "Created in container" in output_file.read_text() | ||
| 344 | |||
| 345 | def test_volume_mount_read_only(self, memres_session, temp_dir): | ||
| 346 | """Test read-only volume mount.""" | ||
| 347 | vdkr = memres_session | ||
| 348 | vdkr.ensure_alpine() | ||
| 349 | |||
| 350 | # Create a test file | ||
| 351 | test_file = temp_dir / "readonly.txt" | ||
| 352 | test_file.write_text("Read-only content") | ||
| 353 | |||
| 354 | # Can read from ro mount | ||
| 355 | result = vdkr.run("vrun", "-v", f"{temp_dir}:/data:ro", "alpine:latest", | ||
| 356 | "cat", "/data/readonly.txt", timeout=60) | ||
| 357 | assert result.returncode == 0 | ||
| 358 | assert "Read-only content" in result.stdout | ||
| 359 | |||
| 360 | def test_volume_mount_multiple(self, memres_session, temp_dir): | ||
| 361 | """Test multiple volume mounts.""" | ||
| 362 | vdkr = memres_session | ||
| 363 | vdkr.ensure_alpine() | ||
| 364 | |||
| 365 | # Create two directories with test files | ||
| 366 | dir1 = temp_dir / "dir1" | ||
| 367 | dir2 = temp_dir / "dir2" | ||
| 368 | dir1.mkdir() | ||
| 369 | dir2.mkdir() | ||
| 370 | |||
| 371 | (dir1 / "file1.txt").write_text("Content from dir1") | ||
| 372 | (dir2 / "file2.txt").write_text("Content from dir2") | ||
| 373 | |||
| 374 | # Create a script to avoid shell metacharacter issues with ';' or '&&' | ||
| 375 | script = temp_dir / "read_both.sh" | ||
| 376 | script.write_text("#!/bin/sh\ncat /data1/file1.txt\ncat /data2/file2.txt\n") | ||
| 377 | script.chmod(0o755) | ||
| 378 | |||
| 379 | # Mount both directories plus the script | ||
| 380 | result = vdkr.run("vrun", | ||
| 381 | "-v", f"{temp_dir}:/scripts", | ||
| 382 | "-v", f"{dir1}:/data1", | ||
| 383 | "-v", f"{dir2}:/data2", | ||
| 384 | "alpine:latest", | ||
| 385 | "/scripts/read_both.sh", | ||
| 386 | timeout=60) | ||
| 387 | assert result.returncode == 0 | ||
| 388 | assert "Content from dir1" in result.stdout | ||
| 389 | assert "Content from dir2" in result.stdout | ||
| 390 | |||
| 391 | def test_volume_mount_with_run_command(self, memres_session, temp_dir): | ||
| 392 | """Test volume mount with run command (not vrun).""" | ||
| 393 | vdkr = memres_session | ||
| 394 | vdkr.ensure_alpine() | ||
| 395 | |||
| 396 | # Create a test file | ||
| 397 | test_file = temp_dir / "runtest.txt" | ||
| 398 | test_file.write_text("Testing run command volumes") | ||
| 399 | |||
| 400 | # Use run command with volume | ||
| 401 | result = vdkr.run("run", "--rm", "-v", f"{temp_dir}:/data", | ||
| 402 | "alpine:latest", "cat", "/data/runtest.txt", | ||
| 403 | timeout=60) | ||
| 404 | assert result.returncode == 0 | ||
| 405 | assert "Testing run command volumes" in result.stdout | ||
| 406 | |||
| 407 | def test_volume_mount_requires_memres(self, vdkr, temp_dir, request): | ||
| 408 | """Test that volume mounts fail gracefully without memres.""" | ||
| 409 | if request.config.getoption("--skip-destructive"): | ||
| 410 | pytest.skip("Skipped with --skip-destructive") | ||
| 411 | |||
| 412 | # Ensure memres is stopped | ||
| 413 | vdkr.memres_stop() | ||
| 414 | |||
| 415 | # Create a test file | ||
| 416 | test_file = temp_dir / "test.txt" | ||
| 417 | test_file.write_text("test") | ||
| 418 | |||
| 419 | # Try to use volume mount without memres - should fail with clear message | ||
| 420 | result = vdkr.run("vrun", "-v", f"{temp_dir}:/data", "alpine:latest", | ||
| 421 | "cat", "/data/test.txt", check=False, timeout=30) | ||
| 422 | |||
| 423 | # Should fail because memres is not running | ||
| 424 | assert result.returncode != 0 | ||
| 425 | assert "memres" in result.stderr.lower() or "daemon" in result.stderr.lower() | ||
| 426 | |||
| 427 | |||
| 428 | class TestSystem: | ||
| 429 | """Test system commands (run inside VM).""" | ||
| 430 | |||
| 431 | def test_system_df(self, memres_session): | ||
| 432 | """Test system df command.""" | ||
| 433 | vdkr = memres_session | ||
| 434 | vdkr.ensure_memres() | ||
| 435 | |||
| 436 | result = vdkr.run("system", "df") | ||
| 437 | assert result.returncode == 0 | ||
| 438 | # Should show images, containers, volumes headers | ||
| 439 | assert "IMAGES" in result.stdout.upper() or "TYPE" in result.stdout.upper() | ||
| 440 | |||
| 441 | def test_system_df_verbose(self, memres_session): | ||
| 442 | """Test system df -v command.""" | ||
| 443 | vdkr = memres_session | ||
| 444 | vdkr.ensure_memres() | ||
| 445 | |||
| 446 | result = vdkr.run("system", "df", "-v") | ||
| 447 | assert result.returncode == 0 | ||
| 448 | # Verbose mode shows more details | ||
| 449 | assert "IMAGES" in result.stdout.upper() or "TYPE" in result.stdout.upper() | ||
| 450 | |||
| 451 | def test_system_prune_dry_run(self, memres_session): | ||
| 452 | """Test system prune with dry run (doesn't actually delete).""" | ||
| 453 | vdkr = memres_session | ||
| 454 | vdkr.ensure_memres() | ||
| 455 | |||
| 456 | # Just verify the command runs (don't actually prune in tests) | ||
| 457 | # Add -f to skip confirmation prompt | ||
| 458 | result = vdkr.run("system", "prune", "-f", check=False) | ||
| 459 | # Command may return 0 even with nothing to prune | ||
| 460 | assert result.returncode == 0 | ||
| 461 | |||
| 462 | def test_system_without_subcommand(self, memres_session): | ||
| 463 | """Test system command without subcommand shows error.""" | ||
| 464 | vdkr = memres_session | ||
| 465 | vdkr.ensure_memres() | ||
| 466 | |||
| 467 | result = vdkr.run("system", check=False) | ||
| 468 | assert result.returncode != 0 | ||
| 469 | assert "subcommand" in result.stderr.lower() or "requires" in result.stderr.lower() | ||
| 470 | |||
| 471 | |||
| 472 | class TestVstorage: | ||
| 473 | """Test vstorage commands (host-side storage management). | ||
| 474 | |||
| 475 | These commands run on the host and don't require memres. | ||
| 476 | """ | ||
| 477 | |||
| 478 | def test_vstorage_list(self, vdkr): | ||
| 479 | """Test vstorage list command.""" | ||
| 480 | # Ensure there's something to list by starting memres briefly | ||
| 481 | vdkr.ensure_memres() | ||
| 482 | |||
| 483 | result = vdkr.run("vstorage", "list", check=False) | ||
| 484 | # vstorage list is an alias for vstorage | ||
| 485 | assert result.returncode == 0 | ||
| 486 | assert "storage" in result.stdout.lower() or "path" in result.stdout.lower() | ||
| 487 | |||
| 488 | def test_vstorage_default(self, vdkr): | ||
| 489 | """Test vstorage with no subcommand (defaults to list).""" | ||
| 490 | vdkr.ensure_memres() | ||
| 491 | |||
| 492 | result = vdkr.run("vstorage", check=False) | ||
| 493 | assert result.returncode == 0 | ||
| 494 | # Should show storage info | ||
| 495 | assert "storage" in result.stdout.lower() or "vdkr" in result.stdout.lower() | ||
| 496 | |||
| 497 | def test_vstorage_path(self, vdkr, arch): | ||
| 498 | """Test vstorage path command.""" | ||
| 499 | result = vdkr.run("vstorage", "path", check=False) | ||
| 500 | assert result.returncode == 0 | ||
| 501 | # Output should contain the architecture or .vdkr path | ||
| 502 | assert arch in result.stdout or ".vdkr" in result.stdout | ||
| 503 | |||
| 504 | def test_vstorage_path_specific_arch(self, vdkr): | ||
| 505 | """Test vstorage path with specific architecture.""" | ||
| 506 | # Use the same arch as the runner to avoid cross-arch issues | ||
| 507 | arch = vdkr.arch | ||
| 508 | result = vdkr.run("vstorage", "path", arch, check=False) | ||
| 509 | assert result.returncode == 0 | ||
| 510 | assert arch in result.stdout | ||
| 511 | |||
| 512 | def test_vstorage_df(self, vdkr): | ||
| 513 | """Test vstorage df command.""" | ||
| 514 | # Ensure there's something to show | ||
| 515 | vdkr.ensure_memres() | ||
| 516 | |||
| 517 | result = vdkr.run("vstorage", "df", check=False) | ||
| 518 | assert result.returncode == 0 | ||
| 519 | # Should show size information (may be empty if no state yet) | ||
| 520 | |||
| 521 | def test_vstorage_shows_memres_status(self, vdkr): | ||
| 522 | """Test that vstorage list shows memres running status.""" | ||
| 523 | vdkr.ensure_memres() | ||
| 524 | |||
| 525 | result = vdkr.run("vstorage", "list", check=False) | ||
| 526 | assert result.returncode == 0 | ||
| 527 | # Should show running status when memres is active | ||
| 528 | assert "running" in result.stdout.lower() or "memres" in result.stdout.lower() \ | ||
| 529 | or "status" in result.stdout.lower() | ||
| 530 | |||
| 531 | def test_vstorage_clean_current_arch(self, vdkr, request): | ||
| 532 | """Test vstorage clean for current architecture.""" | ||
| 533 | if request.config.getoption("--skip-destructive"): | ||
| 534 | pytest.skip("Skipped with --skip-destructive") | ||
| 535 | |||
| 536 | # Ensure there's something to clean | ||
| 537 | vdkr.ensure_memres() | ||
| 538 | vdkr.memres_stop() | ||
| 539 | |||
| 540 | result = vdkr.run("vstorage", "clean", check=False) | ||
| 541 | assert result.returncode == 0 | ||
| 542 | assert "clean" in result.stdout.lower() | ||
| 543 | |||
| 544 | def test_vstorage_unknown_subcommand(self, vdkr): | ||
| 545 | """Test vstorage with unknown subcommand shows error.""" | ||
| 546 | result = vdkr.run("vstorage", "invalid", check=False) | ||
| 547 | assert result.returncode != 0 | ||
| 548 | assert "unknown" in result.stderr.lower() or "usage" in result.stderr.lower() | ||
| 549 | |||
| 550 | |||
| 551 | class TestRun: | ||
| 552 | """Test run command with docker run options.""" | ||
| 553 | |||
| 554 | def test_run_with_entrypoint(self, memres_session): | ||
| 555 | """Test run command with --entrypoint override.""" | ||
| 556 | vdkr = memres_session | ||
| 557 | vdkr.ensure_alpine() | ||
| 558 | |||
| 559 | result = vdkr.run("run", "--rm", "--entrypoint", "/bin/echo", | ||
| 560 | "alpine:latest", "hello", "from", "entrypoint") | ||
| 561 | assert result.returncode == 0 | ||
| 562 | assert "hello from entrypoint" in result.stdout | ||
| 563 | |||
| 564 | def test_run_with_env_var(self, memres_session): | ||
| 565 | """Test run command with environment variable.""" | ||
| 566 | vdkr = memres_session | ||
| 567 | vdkr.ensure_alpine() | ||
| 568 | |||
| 569 | # Use printenv instead of echo $MY_VAR to avoid shell quoting issues | ||
| 570 | result = vdkr.run("run", "--rm", "-e", "MY_VAR=test_value", | ||
| 571 | "alpine:latest", "printenv", "MY_VAR") | ||
| 572 | assert result.returncode == 0 | ||
| 573 | assert "test_value" in result.stdout | ||
| 574 | |||
| 575 | |||
| 576 | class TestRemoteFetchAndCrossInstall: | ||
| 577 | """Test remote container fetch and cross-install workflow. | ||
| 578 | |||
| 579 | These tests verify the full workflow for bundling containers into images: | ||
| 580 | 1. Pull container from remote registry | ||
| 581 | 2. Verify container is functional | ||
| 582 | 3. Export container storage (simulates cross-install bundle) | ||
| 583 | 4. Import storage into fresh state (simulates target boot) | ||
| 584 | 5. Verify container works after import | ||
| 585 | |||
| 586 | Requires network access - use @pytest.mark.network marker. | ||
| 587 | """ | ||
| 588 | |||
| 589 | @pytest.mark.network | ||
| 590 | def test_pull_busybox(self, memres_session): | ||
| 591 | """Test pulling busybox image from registry.""" | ||
| 592 | vdkr = memres_session | ||
| 593 | vdkr.ensure_memres() | ||
| 594 | |||
| 595 | # Pull busybox (very small image, faster than alpine for this test) | ||
| 596 | result = vdkr.pull("busybox:latest", timeout=300) | ||
| 597 | assert result.returncode == 0 | ||
| 598 | |||
| 599 | # Verify it appears in images | ||
| 600 | images = vdkr.images() | ||
| 601 | assert "busybox" in images.stdout | ||
| 602 | |||
| 603 | @pytest.mark.network | ||
| 604 | def test_pull_and_run(self, memres_session): | ||
| 605 | """Test that pulled container can be executed.""" | ||
| 606 | vdkr = memres_session | ||
| 607 | vdkr.ensure_memres() | ||
| 608 | |||
| 609 | # Ensure we have busybox | ||
| 610 | images = vdkr.images() | ||
| 611 | if "busybox" not in images.stdout: | ||
| 612 | vdkr.pull("busybox:latest", timeout=300) | ||
| 613 | |||
| 614 | # Run a command in the pulled container | ||
| 615 | result = vdkr.vrun("busybox:latest", "/bin/echo", "remote_fetch_works") | ||
| 616 | assert result.returncode == 0 | ||
| 617 | assert "remote_fetch_works" in result.stdout | ||
| 618 | |||
| 619 | @pytest.mark.network | ||
| 620 | def test_cross_install_workflow(self, memres_session, temp_dir): | ||
| 621 | """Test full cross-install workflow: pull -> export -> import -> run. | ||
| 622 | |||
| 623 | This simulates: | ||
| 624 | 1. Build host: pull container from registry | ||
| 625 | 2. Build host: export Docker storage to tar (for bundling into image) | ||
| 626 | 3. Target boot: import storage tar | ||
| 627 | 4. Target: run the container | ||
| 628 | |||
| 629 | This is the core workflow for container-cross-install. | ||
| 630 | """ | ||
| 631 | vdkr = memres_session | ||
| 632 | vdkr.ensure_memres() | ||
| 633 | |||
| 634 | # Step 1: Pull container from remote registry | ||
| 635 | images = vdkr.images() | ||
| 636 | if "busybox" not in images.stdout: | ||
| 637 | result = vdkr.pull("busybox:latest", timeout=300) | ||
| 638 | assert result.returncode == 0 | ||
| 639 | |||
| 640 | # Step 2: Save container to tar (simulates bundle export) | ||
| 641 | bundle_tar = temp_dir / "cross-install-bundle.tar" | ||
| 642 | result = vdkr.save(bundle_tar, "busybox:latest", timeout=180) | ||
| 643 | assert result.returncode == 0 | ||
| 644 | assert bundle_tar.exists() | ||
| 645 | assert bundle_tar.stat().st_size > 0 | ||
| 646 | |||
| 647 | # Step 3: Remove original image (simulates fresh target state) | ||
| 648 | vdkr.run("rmi", "-f", "busybox:latest", check=False) | ||
| 649 | images = vdkr.images() | ||
| 650 | # Verify removed (may still show if other tags exist) | ||
| 651 | |||
| 652 | # Step 4: Load from bundle tar (simulates target importing bundled storage) | ||
| 653 | result = vdkr.load(bundle_tar, timeout=180) | ||
| 654 | assert result.returncode == 0 | ||
| 655 | |||
| 656 | # Step 5: Verify container works after import | ||
| 657 | images = vdkr.images() | ||
| 658 | assert "busybox" in images.stdout | ||
| 659 | |||
| 660 | result = vdkr.vrun("busybox:latest", "/bin/echo", "cross_install_success") | ||
| 661 | assert result.returncode == 0 | ||
| 662 | assert "cross_install_success" in result.stdout | ||
| 663 | |||
| 664 | @pytest.mark.network | ||
| 665 | def test_pull_verify_architecture(self, memres_session, arch): | ||
| 666 | """Test that pulled container matches target architecture.""" | ||
| 667 | vdkr = memres_session | ||
| 668 | vdkr.ensure_memres() | ||
| 669 | |||
| 670 | # Ensure we have busybox | ||
| 671 | images = vdkr.images() | ||
| 672 | if "busybox" not in images.stdout: | ||
| 673 | vdkr.pull("busybox:latest", timeout=300) | ||
| 674 | |||
| 675 | # Run uname to verify architecture inside container | ||
| 676 | result = vdkr.vrun("busybox:latest", "/bin/uname", "-m") | ||
| 677 | assert result.returncode == 0 | ||
| 678 | |||
| 679 | # Check architecture matches target | ||
| 680 | expected_arch = "x86_64" if arch == "x86_64" else "aarch64" | ||
| 681 | assert expected_arch in result.stdout, \ | ||
| 682 | f"Architecture mismatch: expected {expected_arch}, got {result.stdout.strip()}" | ||
| 683 | |||
| 684 | @pytest.mark.network | ||
| 685 | def test_multiple_containers_bundle(self, memres_session, temp_dir): | ||
| 686 | """Test bundling multiple containers (simulates multi-container image).""" | ||
| 687 | vdkr = memres_session | ||
| 688 | vdkr.ensure_memres() | ||
| 689 | |||
| 690 | containers = ["busybox:latest", "alpine:latest"] | ||
| 691 | bundle_tars = [] | ||
| 692 | |||
| 693 | # Pull and save each container | ||
| 694 | for container in containers: | ||
| 695 | name = container.split(":")[0] | ||
| 696 | images = vdkr.images() | ||
| 697 | if name not in images.stdout: | ||
| 698 | result = vdkr.pull(container, timeout=300) | ||
| 699 | assert result.returncode == 0 | ||
| 700 | |||
| 701 | tar_path = temp_dir / f"{name}-bundle.tar" | ||
| 702 | result = vdkr.save(tar_path, container, timeout=180) | ||
| 703 | assert result.returncode == 0 | ||
| 704 | bundle_tars.append((container, tar_path)) | ||
| 705 | |||
| 706 | # Remove all containers | ||
| 707 | for container, _ in bundle_tars: | ||
| 708 | vdkr.run("rmi", "-f", container, check=False) | ||
| 709 | |||
| 710 | # Load all bundles (simulates target with multiple bundled containers) | ||
| 711 | for container, tar_path in bundle_tars: | ||
| 712 | result = vdkr.load(tar_path, timeout=180) | ||
| 713 | assert result.returncode == 0 | ||
| 714 | |||
| 715 | # Verify all containers work | ||
| 716 | images = vdkr.images() | ||
| 717 | for container, _ in bundle_tars: | ||
| 718 | name = container.split(":")[0] | ||
| 719 | assert name in images.stdout, f"{name} not found after load" | ||
| 720 | |||
| 721 | # Run a command in each | ||
| 722 | result = vdkr.vrun("busybox:latest", "/bin/echo", "busybox_ok") | ||
| 723 | assert "busybox_ok" in result.stdout | ||
| 724 | |||
| 725 | result = vdkr.vrun("alpine:latest", "/bin/echo", "alpine_ok") | ||
| 726 | assert "alpine_ok" in result.stdout | ||
