diff options
| author | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-12 16:14:00 +0000 |
|---|---|---|
| committer | Bruce Ashfield <bruce.ashfield@gmail.com> | 2026-01-21 18:00:26 -0500 |
| commit | 7418693e425d3355533594481fc224732dd154f5 (patch) | |
| tree | 7e24ede1814bad1caa3e2addb81d7d6780d5bff8 | |
| parent | 65cebeda3a2eda72802a46949601ba3e021a0db6 (diff) | |
| download | meta-virtualization-7418693e425d3355533594481fc224732dd154f5.tar.gz | |
tests: add container registry pytest tests
Add pytest tests for registry functionality:
- test_vdkr_registry.py: vconfig registry, image commands, CLI override
- test_container_registry_script.py: start/stop/push/import/list/tags
- conftest.py: --registry-url, --registry-script options
Signed-off-by: Bruce Ashfield <bruce.ashfield@gmail.com>
| -rw-r--r-- | recipes-containers/container-registry/container-oci-registry-config.bb | 8 | ||||
| -rw-r--r-- | recipes-containers/container-registry/docker-registry-config.bb | 7 | ||||
| -rw-r--r-- | tests/conftest.py | 19 | ||||
| -rw-r--r-- | tests/test_container_registry_script.py | 663 | ||||
| -rw-r--r-- | tests/test_vdkr_registry.py | 414 |
5 files changed, 1106 insertions, 5 deletions
diff --git a/recipes-containers/container-registry/container-oci-registry-config.bb b/recipes-containers/container-registry/container-oci-registry-config.bb index ee6760f4..ba8cf4c3 100644 --- a/recipes-containers/container-registry/container-oci-registry-config.bb +++ b/recipes-containers/container-registry/container-oci-registry-config.bb | |||
| @@ -22,16 +22,18 @@ | |||
| 22 | # | 22 | # |
| 23 | # IMPORTANT: This recipe: | 23 | # IMPORTANT: This recipe: |
| 24 | # - Does NOT modify docker-distribution or container-host-config | 24 | # - Does NOT modify docker-distribution or container-host-config |
| 25 | # - Does NOT install automatically - user must add to IMAGE_INSTALL | ||
| 26 | # - Does NOT clobber public registry access (docker.io, quay.io, etc.) | 25 | # - Does NOT clobber public registry access (docker.io, quay.io, etc.) |
| 27 | # - Uses drop-in files in /etc/containers/registries.conf.d/ | 26 | # - Uses drop-in files in /etc/containers/registries.conf.d/ |
| 28 | # - Skips entirely if CONTAINER_REGISTRY_URL is not set | 27 | # - Skips entirely if CONTAINER_REGISTRY_URL is not set |
| 29 | # | 28 | # |
| 30 | # Usage: | 29 | # Usage: |
| 31 | # # In local.conf or image recipe - BOTH required: | 30 | # # In local.conf or image recipe: |
| 32 | # CONTAINER_REGISTRY_URL = "localhost:5000" | 31 | # CONTAINER_REGISTRY_URL = "localhost:5000" |
| 33 | # CONTAINER_REGISTRY_INSECURE = "1" | 32 | # CONTAINER_REGISTRY_INSECURE = "1" |
| 34 | # IMAGE_INSTALL:append = " container-oci-registry-config" | 33 | # IMAGE_FEATURES += "container-registry" |
| 34 | # | ||
| 35 | # The IMAGE_FEATURES mechanism auto-selects this recipe for Podman/CRI-O | ||
| 36 | # or docker-registry-config for Docker based on VIRTUAL-RUNTIME_container_engine. | ||
| 35 | # | 37 | # |
| 36 | # =========================================================================== | 38 | # =========================================================================== |
| 37 | 39 | ||
diff --git a/recipes-containers/container-registry/docker-registry-config.bb b/recipes-containers/container-registry/docker-registry-config.bb index eee74c98..cbe2e13f 100644 --- a/recipes-containers/container-registry/docker-registry-config.bb +++ b/recipes-containers/container-registry/docker-registry-config.bb | |||
| @@ -20,7 +20,6 @@ | |||
| 20 | # This config only handles insecure registry trust. | 20 | # This config only handles insecure registry trust. |
| 21 | # | 21 | # |
| 22 | # IMPORTANT: This recipe: | 22 | # IMPORTANT: This recipe: |
| 23 | # - Does NOT install automatically - user must add to IMAGE_INSTALL | ||
| 24 | # - Skips entirely if DOCKER_REGISTRY_INSECURE is not set | 23 | # - Skips entirely if DOCKER_REGISTRY_INSECURE is not set |
| 25 | # - Creates /etc/docker/daemon.json (will be merged if docker recipe | 24 | # - Creates /etc/docker/daemon.json (will be merged if docker recipe |
| 26 | # also creates one, or may need RCONFLICTS handling) | 25 | # also creates one, or may need RCONFLICTS handling) |
| @@ -28,7 +27,11 @@ | |||
| 28 | # Usage: | 27 | # Usage: |
| 29 | # # In local.conf or image recipe: | 28 | # # In local.conf or image recipe: |
| 30 | # DOCKER_REGISTRY_INSECURE = "10.0.2.2:5000 myregistry.local:5000" | 29 | # DOCKER_REGISTRY_INSECURE = "10.0.2.2:5000 myregistry.local:5000" |
| 31 | # IMAGE_INSTALL:append = " docker-registry-config" | 30 | # IMAGE_FEATURES += "container-registry" |
| 31 | # | ||
| 32 | # The IMAGE_FEATURES mechanism auto-selects this recipe for Docker | ||
| 33 | # or container-oci-registry-config for Podman/CRI-O based on | ||
| 34 | # VIRTUAL-RUNTIME_container_engine. | ||
| 32 | # | 35 | # |
| 33 | # =========================================================================== | 36 | # =========================================================================== |
| 34 | 37 | ||
diff --git a/tests/conftest.py b/tests/conftest.py index 712700ee..accb8f17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py | |||
| @@ -226,6 +226,25 @@ def pytest_addoption(parser): | |||
| 226 | default=24.0, | 226 | default=24.0, |
| 227 | help="Max rootfs age in hours before warning (default: 24)", | 227 | help="Max rootfs age in hours before warning (default: 24)", |
| 228 | ) | 228 | ) |
| 229 | # Container registry options | ||
| 230 | parser.addoption( | ||
| 231 | "--registry-url", | ||
| 232 | action="store", | ||
| 233 | default=os.environ.get("TEST_REGISTRY_URL"), | ||
| 234 | help="Registry URL for vdkr registry tests (e.g., 10.0.2.2:5000/yocto)", | ||
| 235 | ) | ||
| 236 | parser.addoption( | ||
| 237 | "--registry-script", | ||
| 238 | action="store", | ||
| 239 | default=os.environ.get("CONTAINER_REGISTRY_SCRIPT"), | ||
| 240 | help="Path to container-registry.sh script", | ||
| 241 | ) | ||
| 242 | parser.addoption( | ||
| 243 | "--skip-registry-network", | ||
| 244 | action="store_true", | ||
| 245 | default=False, | ||
| 246 | help="Skip registry tests that require network access to docker.io", | ||
| 247 | ) | ||
| 229 | 248 | ||
| 230 | 249 | ||
| 231 | def _cleanup_stale_test_state(): | 250 | def _cleanup_stale_test_state(): |
diff --git a/tests/test_container_registry_script.py b/tests/test_container_registry_script.py new file mode 100644 index 00000000..444c5d3b --- /dev/null +++ b/tests/test_container_registry_script.py | |||
| @@ -0,0 +1,663 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for container-registry.sh helper script. | ||
| 6 | |||
| 7 | These tests verify the container registry helper script commands: | ||
| 8 | - start/stop/status - Registry server lifecycle | ||
| 9 | - push - Push OCI images to registry (with tag/strategy options) | ||
| 10 | - import - Import 3rd party images | ||
| 11 | - delete - Delete tagged images | ||
| 12 | - gc - Garbage collection | ||
| 13 | - list/tags/catalog - Query registry contents | ||
| 14 | |||
| 15 | Prerequisites: | ||
| 16 | # Generate the script first: | ||
| 17 | bitbake container-registry-index -c generate_registry_script | ||
| 18 | |||
| 19 | # The script location is: | ||
| 20 | $TOPDIR/container-registry/container-registry.sh | ||
| 21 | |||
| 22 | Run with: | ||
| 23 | pytest tests/test_container_registry_script.py -v | ||
| 24 | |||
| 25 | Run with specific registry script: | ||
| 26 | pytest tests/test_container_registry_script.py -v \\ | ||
| 27 | --registry-script /path/to/container-registry.sh | ||
| 28 | |||
| 29 | Environment variables: | ||
| 30 | CONTAINER_REGISTRY_SCRIPT: Path to the registry script | ||
| 31 | TOPDIR: Yocto build directory (script at $TOPDIR/container-registry/) | ||
| 32 | """ | ||
| 33 | |||
| 34 | import pytest | ||
| 35 | import subprocess | ||
| 36 | import os | ||
| 37 | import time | ||
| 38 | from pathlib import Path | ||
| 39 | |||
| 40 | |||
| 41 | # Note: Registry options (--registry-script, --skip-registry-network) | ||
| 42 | # are defined in conftest.py | ||
| 43 | |||
| 44 | |||
| 45 | @pytest.fixture(scope="module") | ||
| 46 | def registry_script(request): | ||
| 47 | """Get path to the registry script. | ||
| 48 | |||
| 49 | Looks in order: | ||
| 50 | 1. --registry-script command line option | ||
| 51 | 2. CONTAINER_REGISTRY_SCRIPT environment variable | ||
| 52 | 3. $TOPDIR/container-registry/container-registry.sh | ||
| 53 | 4. Common locations based on cwd | ||
| 54 | """ | ||
| 55 | # Check command line option | ||
| 56 | script_path = request.config.getoption("--registry-script", default=None) | ||
| 57 | |||
| 58 | if script_path is None: | ||
| 59 | # Check environment variable | ||
| 60 | script_path = os.environ.get("CONTAINER_REGISTRY_SCRIPT") | ||
| 61 | |||
| 62 | if script_path is None: | ||
| 63 | # Try TOPDIR-based path | ||
| 64 | topdir = os.environ.get("TOPDIR") | ||
| 65 | if topdir: | ||
| 66 | script_path = os.path.join(topdir, "container-registry", "container-registry.sh") | ||
| 67 | |||
| 68 | if script_path is None: | ||
| 69 | # Try common locations relative to cwd | ||
| 70 | candidates = [ | ||
| 71 | "container-registry/container-registry.sh", | ||
| 72 | "../container-registry/container-registry.sh", | ||
| 73 | "build/container-registry/container-registry.sh", | ||
| 74 | ] | ||
| 75 | for candidate in candidates: | ||
| 76 | if os.path.exists(candidate): | ||
| 77 | script_path = candidate | ||
| 78 | break | ||
| 79 | |||
| 80 | if script_path is None or not os.path.exists(script_path): | ||
| 81 | pytest.skip( | ||
| 82 | "Registry script not found. Generate it with: " | ||
| 83 | "bitbake container-registry-index -c generate_registry_script\n" | ||
| 84 | "Or specify path with --registry-script or CONTAINER_REGISTRY_SCRIPT env var" | ||
| 85 | ) | ||
| 86 | |||
| 87 | script_path = Path(script_path).resolve() | ||
| 88 | if not script_path.exists(): | ||
| 89 | pytest.skip(f"Registry script not found at: {script_path}") | ||
| 90 | |||
| 91 | return script_path | ||
| 92 | |||
| 93 | |||
| 94 | @pytest.fixture(scope="module") | ||
| 95 | def skip_network(request): | ||
| 96 | """Check if network tests should be skipped.""" | ||
| 97 | return request.config.getoption("--skip-registry-network", default=False) | ||
| 98 | |||
| 99 | |||
| 100 | class RegistryScriptRunner: | ||
| 101 | """Helper class for running registry script commands.""" | ||
| 102 | |||
| 103 | def __init__(self, script_path: Path): | ||
| 104 | self.script_path = script_path | ||
| 105 | self._was_running = None | ||
| 106 | |||
| 107 | def run(self, *args, timeout=30, check=True, capture_output=True): | ||
| 108 | """Run a registry script command.""" | ||
| 109 | cmd = [str(self.script_path)] + list(args) | ||
| 110 | result = subprocess.run( | ||
| 111 | cmd, | ||
| 112 | timeout=timeout, | ||
| 113 | check=False, | ||
| 114 | capture_output=capture_output, | ||
| 115 | text=True, | ||
| 116 | ) | ||
| 117 | if check and result.returncode != 0: | ||
| 118 | error_msg = f"Command failed: {' '.join(cmd)}\n" | ||
| 119 | error_msg += f"Exit code: {result.returncode}\n" | ||
| 120 | if result.stdout: | ||
| 121 | error_msg += f"stdout: {result.stdout}\n" | ||
| 122 | if result.stderr: | ||
| 123 | error_msg += f"stderr: {result.stderr}\n" | ||
| 124 | raise AssertionError(error_msg) | ||
| 125 | return result | ||
| 126 | |||
| 127 | def start(self, timeout=30): | ||
| 128 | """Start the registry.""" | ||
| 129 | return self.run("start", timeout=timeout) | ||
| 130 | |||
| 131 | def stop(self, timeout=10): | ||
| 132 | """Stop the registry.""" | ||
| 133 | return self.run("stop", timeout=timeout, check=False) | ||
| 134 | |||
| 135 | def status(self, timeout=10): | ||
| 136 | """Check registry status.""" | ||
| 137 | return self.run("status", timeout=timeout, check=False) | ||
| 138 | |||
| 139 | def is_running(self): | ||
| 140 | """Check if registry is running.""" | ||
| 141 | result = self.status() | ||
| 142 | return result.returncode == 0 and "running" in result.stdout.lower() | ||
| 143 | |||
| 144 | def ensure_running(self, timeout=30): | ||
| 145 | """Ensure registry is running, starting if needed.""" | ||
| 146 | if not self.is_running(): | ||
| 147 | result = self.start(timeout=timeout) | ||
| 148 | if result.returncode != 0: | ||
| 149 | raise RuntimeError(f"Failed to start registry: {result.stderr}") | ||
| 150 | time.sleep(2) | ||
| 151 | |||
| 152 | def push(self, timeout=120): | ||
| 153 | """Push OCI images to registry.""" | ||
| 154 | return self.run("push", timeout=timeout) | ||
| 155 | |||
| 156 | def import_image(self, source, dest_name=None, timeout=300): | ||
| 157 | """Import a 3rd party image.""" | ||
| 158 | args = ["import", source] | ||
| 159 | if dest_name: | ||
| 160 | args.append(dest_name) | ||
| 161 | return self.run(*args, timeout=timeout) | ||
| 162 | |||
| 163 | def list_images(self, timeout=30): | ||
| 164 | """List images in registry.""" | ||
| 165 | return self.run("list", timeout=timeout) | ||
| 166 | |||
| 167 | def tags(self, image, timeout=30): | ||
| 168 | """Get tags for an image.""" | ||
| 169 | return self.run("tags", image, timeout=timeout, check=False) | ||
| 170 | |||
| 171 | def catalog(self, timeout=30): | ||
| 172 | """Get raw catalog.""" | ||
| 173 | return self.run("catalog", timeout=timeout) | ||
| 174 | |||
| 175 | def help(self): | ||
| 176 | """Show help.""" | ||
| 177 | return self.run("help", check=False) | ||
| 178 | |||
| 179 | def delete(self, image_tag, timeout=30): | ||
| 180 | """Delete a tagged image.""" | ||
| 181 | return self.run("delete", image_tag, timeout=timeout, check=False) | ||
| 182 | |||
| 183 | def gc(self, timeout=60): | ||
| 184 | """Run garbage collection (non-interactive).""" | ||
| 185 | # gc prompts for confirmation, so we can't easily test interactive mode | ||
| 186 | # Just test that the command exists and shows dry-run | ||
| 187 | return self.run("gc", timeout=timeout, check=False) | ||
| 188 | |||
| 189 | def push_with_args(self, *args, timeout=120): | ||
| 190 | """Push with custom arguments.""" | ||
| 191 | return self.run("push", *args, timeout=timeout, check=False) | ||
| 192 | |||
| 193 | |||
| 194 | @pytest.fixture(scope="module") | ||
| 195 | def registry(registry_script): | ||
| 196 | """Create a RegistryScriptRunner instance.""" | ||
| 197 | return RegistryScriptRunner(registry_script) | ||
| 198 | |||
| 199 | |||
| 200 | @pytest.fixture(scope="module") | ||
| 201 | def registry_session(registry): | ||
| 202 | """Module-scoped fixture that ensures registry is running. | ||
| 203 | |||
| 204 | Starts the registry if not running and stops it at the end | ||
| 205 | if we started it. | ||
| 206 | """ | ||
| 207 | was_running = registry.is_running() | ||
| 208 | |||
| 209 | if not was_running: | ||
| 210 | result = registry.start(timeout=30) | ||
| 211 | if result.returncode != 0: | ||
| 212 | pytest.skip(f"Failed to start registry: {result.stderr}") | ||
| 213 | # Wait a moment for registry to be ready | ||
| 214 | time.sleep(2) | ||
| 215 | |||
| 216 | yield registry | ||
| 217 | |||
| 218 | # Only stop if we started it | ||
| 219 | if not was_running: | ||
| 220 | registry.stop() | ||
| 221 | |||
| 222 | |||
| 223 | class TestRegistryScriptBasic: | ||
| 224 | """Test basic registry script functionality.""" | ||
| 225 | |||
| 226 | def test_script_exists_and_executable(self, registry_script): | ||
| 227 | """Test that the script exists and is executable.""" | ||
| 228 | assert registry_script.exists() | ||
| 229 | assert os.access(registry_script, os.X_OK) | ||
| 230 | |||
| 231 | def test_help_command(self, registry): | ||
| 232 | """Test help command shows usage info.""" | ||
| 233 | result = registry.help() | ||
| 234 | assert result.returncode == 0 | ||
| 235 | assert "start" in result.stdout | ||
| 236 | assert "stop" in result.stdout | ||
| 237 | assert "push" in result.stdout | ||
| 238 | assert "import" in result.stdout | ||
| 239 | assert "list" in result.stdout | ||
| 240 | |||
| 241 | def test_unknown_command_shows_error(self, registry): | ||
| 242 | """Test that unknown command shows error and help.""" | ||
| 243 | result = registry.run("invalid-command", check=False) | ||
| 244 | assert result.returncode != 0 | ||
| 245 | assert "unknown" in result.stdout.lower() or "usage" in result.stdout.lower() | ||
| 246 | |||
| 247 | |||
| 248 | class TestRegistryLifecycle: | ||
| 249 | """Test registry start/stop/status commands.""" | ||
| 250 | |||
| 251 | def test_start_registry(self, registry): | ||
| 252 | """Test starting the registry.""" | ||
| 253 | # Stop first if running | ||
| 254 | registry.stop() | ||
| 255 | time.sleep(1) | ||
| 256 | |||
| 257 | result = registry.start() | ||
| 258 | assert result.returncode == 0 | ||
| 259 | assert "started" in result.stdout.lower() or "running" in result.stdout.lower() | ||
| 260 | |||
| 261 | # Verify it's running | ||
| 262 | assert registry.is_running() | ||
| 263 | |||
| 264 | def test_status_when_running(self, registry): | ||
| 265 | """Test status command when registry is running.""" | ||
| 266 | # Ensure running | ||
| 267 | if not registry.is_running(): | ||
| 268 | registry.start() | ||
| 269 | time.sleep(2) | ||
| 270 | |||
| 271 | result = registry.status() | ||
| 272 | assert result.returncode == 0 | ||
| 273 | assert "running" in result.stdout.lower() | ||
| 274 | assert "healthy" in result.stdout.lower() or "url" in result.stdout.lower() | ||
| 275 | |||
| 276 | def test_stop_registry(self, registry): | ||
| 277 | """Test stopping the registry.""" | ||
| 278 | # Ensure running first | ||
| 279 | if not registry.is_running(): | ||
| 280 | registry.start() | ||
| 281 | time.sleep(2) | ||
| 282 | |||
| 283 | result = registry.stop() | ||
| 284 | assert result.returncode == 0 | ||
| 285 | assert "stop" in result.stdout.lower() | ||
| 286 | |||
| 287 | # Verify it's stopped | ||
| 288 | assert not registry.is_running() | ||
| 289 | |||
| 290 | def test_status_when_stopped(self, registry): | ||
| 291 | """Test status command when registry is stopped.""" | ||
| 292 | # Ensure stopped | ||
| 293 | registry.stop() | ||
| 294 | time.sleep(1) | ||
| 295 | |||
| 296 | result = registry.status() | ||
| 297 | assert result.returncode != 0 | ||
| 298 | assert "not running" in result.stdout.lower() | ||
| 299 | |||
| 300 | def test_start_when_already_running(self, registry): | ||
| 301 | """Test that starting when already running is idempotent.""" | ||
| 302 | # Start once | ||
| 303 | if not registry.is_running(): | ||
| 304 | registry.start() | ||
| 305 | time.sleep(2) | ||
| 306 | |||
| 307 | # Start again | ||
| 308 | result = registry.start() | ||
| 309 | assert result.returncode == 0 | ||
| 310 | assert "already running" in result.stdout.lower() or "running" in result.stdout.lower() | ||
| 311 | |||
| 312 | def test_stop_when_not_running(self, registry): | ||
| 313 | """Test that stopping when not running is idempotent.""" | ||
| 314 | # Ensure stopped | ||
| 315 | registry.stop() | ||
| 316 | time.sleep(1) | ||
| 317 | |||
| 318 | # Stop again | ||
| 319 | result = registry.stop() | ||
| 320 | assert result.returncode == 0 | ||
| 321 | assert "not running" in result.stdout.lower() | ||
| 322 | |||
| 323 | |||
| 324 | class TestRegistryPush: | ||
| 325 | """Test pushing OCI images to the registry. | ||
| 326 | |||
| 327 | Note: This requires OCI images in the deploy directory. | ||
| 328 | Tests will skip if no images are available. | ||
| 329 | """ | ||
| 330 | |||
| 331 | def test_push_requires_running_registry(self, registry): | ||
| 332 | """Test that push fails when registry is not running.""" | ||
| 333 | registry.stop() | ||
| 334 | time.sleep(1) | ||
| 335 | |||
| 336 | result = registry.run("push", check=False, timeout=10) | ||
| 337 | assert result.returncode != 0 | ||
| 338 | assert "not responding" in result.stdout.lower() or "start" in result.stdout.lower() | ||
| 339 | |||
| 340 | def test_push_with_no_images(self, registry_session): | ||
| 341 | """Test push when no OCI images are in deploy directory. | ||
| 342 | |||
| 343 | This may succeed (with "no images" message) or actually push | ||
| 344 | images if they exist. Either is acceptable. | ||
| 345 | """ | ||
| 346 | registry_session.ensure_running() | ||
| 347 | result = registry_session.push(timeout=120) | ||
| 348 | # Either succeeds (with images) or shows message (without) | ||
| 349 | # Both are valid outcomes | ||
| 350 | assert result.returncode == 0 | ||
| 351 | |||
| 352 | |||
| 353 | class TestRegistryImport: | ||
| 354 | """Test importing 3rd party images. | ||
| 355 | |||
| 356 | Note: Import tests require network access to docker.io. | ||
| 357 | Use --skip-registry-network to skip these tests. | ||
| 358 | """ | ||
| 359 | |||
| 360 | def test_import_requires_running_registry(self, registry): | ||
| 361 | """Test that import fails when registry is not running.""" | ||
| 362 | registry.stop() | ||
| 363 | time.sleep(1) | ||
| 364 | |||
| 365 | result = registry.run("import", "docker.io/library/alpine:latest", | ||
| 366 | check=False, timeout=10) | ||
| 367 | assert result.returncode != 0 | ||
| 368 | assert "not responding" in result.stdout.lower() or "start" in result.stdout.lower() | ||
| 369 | |||
| 370 | def test_import_no_args_shows_usage(self, registry_session): | ||
| 371 | """Test that import without args shows usage.""" | ||
| 372 | registry_session.ensure_running() | ||
| 373 | result = registry_session.run("import", check=False) | ||
| 374 | assert result.returncode != 0 | ||
| 375 | assert "usage" in result.stdout.lower() | ||
| 376 | assert "docker.io" in result.stdout.lower() or "example" in result.stdout.lower() | ||
| 377 | |||
| 378 | @pytest.mark.network | ||
| 379 | @pytest.mark.slow | ||
| 380 | def test_import_alpine(self, registry_session, skip_network): | ||
| 381 | """Test importing alpine from docker.io.""" | ||
| 382 | if skip_network: | ||
| 383 | pytest.skip("Skipping network test (--skip-registry-network)") | ||
| 384 | |||
| 385 | registry_session.ensure_running() | ||
| 386 | result = registry_session.import_image( | ||
| 387 | "docker.io/library/alpine:latest", | ||
| 388 | timeout=300 | ||
| 389 | ) | ||
| 390 | assert result.returncode == 0 | ||
| 391 | assert "import complete" in result.stdout.lower() or "importing" in result.stdout.lower() | ||
| 392 | |||
| 393 | # Verify it appears in list | ||
| 394 | list_result = registry_session.list_images() | ||
| 395 | assert "alpine" in list_result.stdout | ||
| 396 | |||
| 397 | @pytest.mark.network | ||
| 398 | @pytest.mark.slow | ||
| 399 | def test_import_with_custom_name(self, registry_session, skip_network): | ||
| 400 | """Test importing with a custom local name.""" | ||
| 401 | if skip_network: | ||
| 402 | pytest.skip("Skipping network test (--skip-registry-network)") | ||
| 403 | |||
| 404 | registry_session.ensure_running() | ||
| 405 | result = registry_session.import_image( | ||
| 406 | "docker.io/library/busybox:latest", | ||
| 407 | "my-busybox", | ||
| 408 | timeout=300 | ||
| 409 | ) | ||
| 410 | assert result.returncode == 0 | ||
| 411 | |||
| 412 | # Verify it appears with custom name | ||
| 413 | list_result = registry_session.list_images() | ||
| 414 | assert "my-busybox" in list_result.stdout | ||
| 415 | |||
| 416 | |||
| 417 | class TestRegistryQuery: | ||
| 418 | """Test registry query commands (list, tags, catalog).""" | ||
| 419 | |||
| 420 | def test_catalog_requires_running_registry(self, registry): | ||
| 421 | """Test that catalog fails when registry is not running.""" | ||
| 422 | registry.stop() | ||
| 423 | time.sleep(1) | ||
| 424 | |||
| 425 | result = registry.run("catalog", check=False, timeout=10) | ||
| 426 | # May fail or return empty/error JSON | ||
| 427 | # Just verify it doesn't hang | ||
| 428 | |||
| 429 | def test_list_requires_running_registry(self, registry): | ||
| 430 | """Test that list fails when registry is not running.""" | ||
| 431 | registry.stop() | ||
| 432 | time.sleep(1) | ||
| 433 | |||
| 434 | result = registry.run("list", check=False, timeout=10) | ||
| 435 | assert result.returncode != 0 | ||
| 436 | assert "not responding" in result.stdout.lower() | ||
| 437 | |||
| 438 | def test_catalog_returns_json(self, registry_session): | ||
| 439 | """Test that catalog returns JSON format.""" | ||
| 440 | registry_session.ensure_running() | ||
| 441 | result = registry_session.catalog() | ||
| 442 | assert result.returncode == 0 | ||
| 443 | |||
| 444 | # Should be valid JSON with repositories key | ||
| 445 | import json | ||
| 446 | try: | ||
| 447 | data = json.loads(result.stdout) | ||
| 448 | assert "repositories" in data | ||
| 449 | except json.JSONDecodeError: | ||
| 450 | # May be pretty-printed, try parsing lines | ||
| 451 | assert "repositories" in result.stdout | ||
| 452 | |||
| 453 | def test_list_shows_images(self, registry_session): | ||
| 454 | """Test that list shows images with their tags.""" | ||
| 455 | registry_session.ensure_running() | ||
| 456 | result = registry_session.list_images() | ||
| 457 | assert result.returncode == 0 | ||
| 458 | # Should show header or images | ||
| 459 | assert "images" in result.stdout.lower() or ":" in result.stdout or "(none)" in result.stdout | ||
| 460 | |||
| 461 | def test_tags_for_nonexistent_image(self, registry_session): | ||
| 462 | """Test tags command for nonexistent image.""" | ||
| 463 | registry_session.ensure_running() | ||
| 464 | result = registry_session.tags("nonexistent-image-xyz") | ||
| 465 | # Either returns non-zero with "not found", or returns empty/error JSON | ||
| 466 | # The important thing is it doesn't crash and indicates the image doesn't exist | ||
| 467 | if result.returncode == 0: | ||
| 468 | # If it returns 0, stdout should be empty or contain error info | ||
| 469 | assert "nonexistent" not in result.stdout.lower() or "error" in result.stdout.lower() or result.stdout.strip() == "" | ||
| 470 | else: | ||
| 471 | assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() | ||
| 472 | |||
| 473 | def test_tags_usage_without_image(self, registry_session): | ||
| 474 | """Test tags command without image argument shows usage.""" | ||
| 475 | registry_session.ensure_running() | ||
| 476 | result = registry_session.run("tags", check=False) | ||
| 477 | assert result.returncode != 0 | ||
| 478 | assert "usage" in result.stdout.lower() | ||
| 479 | |||
| 480 | |||
| 481 | class TestRegistryDelete: | ||
| 482 | """Test delete command for removing tagged images.""" | ||
| 483 | |||
| 484 | def test_delete_requires_running_registry(self, registry): | ||
| 485 | """Test that delete fails when registry is not running.""" | ||
| 486 | registry.stop() | ||
| 487 | time.sleep(1) | ||
| 488 | |||
| 489 | result = registry.delete("container-base:latest") | ||
| 490 | assert result.returncode != 0 | ||
| 491 | assert "not responding" in result.stdout.lower() | ||
| 492 | |||
| 493 | def test_delete_no_args_shows_usage(self, registry_session): | ||
| 494 | """Test that delete without args shows usage.""" | ||
| 495 | registry_session.ensure_running() | ||
| 496 | result = registry_session.run("delete", check=False) | ||
| 497 | assert result.returncode != 0 | ||
| 498 | assert "usage" in result.stdout.lower() | ||
| 499 | |||
| 500 | def test_delete_requires_tag(self, registry_session): | ||
| 501 | """Test that delete requires image:tag format.""" | ||
| 502 | registry_session.ensure_running() | ||
| 503 | result = registry_session.delete("container-base") # No tag | ||
| 504 | assert result.returncode != 0 | ||
| 505 | assert "tag required" in result.stdout.lower() | ||
| 506 | |||
| 507 | def test_delete_nonexistent_tag(self, registry_session): | ||
| 508 | """Test deleting a nonexistent tag.""" | ||
| 509 | registry_session.ensure_running() | ||
| 510 | result = registry_session.delete("container-base:nonexistent-tag-xyz") | ||
| 511 | assert result.returncode != 0 | ||
| 512 | assert "not found" in result.stdout.lower() | ||
| 513 | |||
| 514 | @pytest.mark.network | ||
| 515 | @pytest.mark.slow | ||
| 516 | def test_delete_workflow(self, registry_session, skip_network): | ||
| 517 | """Test importing an image, then deleting it.""" | ||
| 518 | if skip_network: | ||
| 519 | pytest.skip("Skipping network test (--skip-registry-network)") | ||
| 520 | |||
| 521 | registry_session.ensure_running() | ||
| 522 | |||
| 523 | # Import an image with unique name | ||
| 524 | result = registry_session.import_image( | ||
| 525 | "docker.io/library/alpine:latest", | ||
| 526 | "delete-test", | ||
| 527 | timeout=300 | ||
| 528 | ) | ||
| 529 | assert result.returncode == 0 | ||
| 530 | |||
| 531 | # Verify it exists | ||
| 532 | result = registry_session.tags("delete-test") | ||
| 533 | assert result.returncode == 0 | ||
| 534 | assert "latest" in result.stdout | ||
| 535 | |||
| 536 | # Delete it | ||
| 537 | result = registry_session.delete("delete-test:latest") | ||
| 538 | assert result.returncode == 0 | ||
| 539 | assert "deleted successfully" in result.stdout.lower() | ||
| 540 | |||
| 541 | # Verify it's gone | ||
| 542 | result = registry_session.tags("delete-test") | ||
| 543 | assert result.returncode != 0 or "not found" in result.stdout.lower() | ||
| 544 | |||
| 545 | |||
| 546 | class TestRegistryGC: | ||
| 547 | """Test garbage collection command.""" | ||
| 548 | |||
| 549 | def test_gc_help_in_help_output(self, registry): | ||
| 550 | """Test that gc command is listed in help.""" | ||
| 551 | result = registry.help() | ||
| 552 | assert "gc" in result.stdout.lower() | ||
| 553 | |||
| 554 | def test_gc_requires_registry_binary(self, registry_session): | ||
| 555 | """Test that gc checks for registry binary. | ||
| 556 | |||
| 557 | This test just verifies gc command runs and either: | ||
| 558 | - Works (shows dry-run output) | ||
| 559 | - Fails with useful error message | ||
| 560 | """ | ||
| 561 | # gc stops registry first, so just run it and check output | ||
| 562 | result = registry_session.gc(timeout=30) | ||
| 563 | # Should either work or show error about binary/not running | ||
| 564 | output = result.stdout.lower() | ||
| 565 | assert any([ | ||
| 566 | "garbage" in output, | ||
| 567 | "collecting" in output, | ||
| 568 | "registry" in output, | ||
| 569 | "error" in output, | ||
| 570 | "not found" in output, | ||
| 571 | ]) | ||
| 572 | |||
| 573 | |||
| 574 | class TestRegistryPushOptions: | ||
| 575 | """Test push command with various options.""" | ||
| 576 | |||
| 577 | def test_push_tag_requires_image_name(self, registry_session): | ||
| 578 | """Test that --tag without image name fails.""" | ||
| 579 | registry_session.ensure_running() | ||
| 580 | result = registry_session.push_with_args("--tag", "v1.0.0") | ||
| 581 | assert result.returncode != 0 | ||
| 582 | assert "--tag requires an image name" in result.stdout.lower() | ||
| 583 | |||
| 584 | def test_push_with_image_filter(self, registry_session): | ||
| 585 | """Test pushing a specific image by name.""" | ||
| 586 | registry_session.ensure_running() | ||
| 587 | result = registry_session.push_with_args("container-base") | ||
| 588 | # Should either succeed or report image not found | ||
| 589 | # (depending on whether container-base exists) | ||
| 590 | output = result.stdout.lower() | ||
| 591 | assert any([ | ||
| 592 | "pushing" in output, | ||
| 593 | "not found" in output, | ||
| 594 | "done" in output, | ||
| 595 | ]) | ||
| 596 | |||
| 597 | def test_push_with_strategy(self, registry_session): | ||
| 598 | """Test pushing with explicit strategy.""" | ||
| 599 | registry_session.ensure_running() | ||
| 600 | result = registry_session.push_with_args("--strategy", "latest") | ||
| 601 | assert result.returncode == 0 or "pushing" in result.stdout.lower() | ||
| 602 | |||
| 603 | def test_push_help_shows_options(self, registry): | ||
| 604 | """Test that help shows push options.""" | ||
| 605 | result = registry.help() | ||
| 606 | assert "--tag" in result.stdout | ||
| 607 | assert "--strategy" in result.stdout | ||
| 608 | assert "image" in result.stdout.lower() | ||
| 609 | |||
| 610 | |||
| 611 | class TestRegistryIntegration: | ||
| 612 | """Integration tests for full registry workflow. | ||
| 613 | |||
| 614 | These tests require: | ||
| 615 | - Registry script generated | ||
| 616 | - docker-distribution-native built | ||
| 617 | - skopeo-native built | ||
| 618 | - Network access (for import tests) | ||
| 619 | """ | ||
| 620 | |||
| 621 | @pytest.mark.network | ||
| 622 | @pytest.mark.slow | ||
| 623 | def test_full_workflow(self, registry, skip_network): | ||
| 624 | """Test complete workflow: start -> import -> list -> stop.""" | ||
| 625 | if skip_network: | ||
| 626 | pytest.skip("Skipping network test (--skip-registry-network)") | ||
| 627 | |||
| 628 | # Start fresh | ||
| 629 | registry.stop() | ||
| 630 | time.sleep(1) | ||
| 631 | |||
| 632 | try: | ||
| 633 | # Start | ||
| 634 | result = registry.start() | ||
| 635 | assert result.returncode == 0 | ||
| 636 | time.sleep(2) | ||
| 637 | |||
| 638 | # Import an image | ||
| 639 | result = registry.import_image( | ||
| 640 | "docker.io/library/alpine:latest", | ||
| 641 | "workflow-test", | ||
| 642 | timeout=300 | ||
| 643 | ) | ||
| 644 | assert result.returncode == 0 | ||
| 645 | |||
| 646 | # List should show it | ||
| 647 | result = registry.list_images() | ||
| 648 | assert result.returncode == 0 | ||
| 649 | assert "workflow-test" in result.stdout | ||
| 650 | |||
| 651 | # Tags should work | ||
| 652 | result = registry.tags("workflow-test") | ||
| 653 | assert result.returncode == 0 | ||
| 654 | assert "latest" in result.stdout | ||
| 655 | |||
| 656 | # Catalog should include it | ||
| 657 | result = registry.catalog() | ||
| 658 | assert result.returncode == 0 | ||
| 659 | assert "workflow-test" in result.stdout | ||
| 660 | |||
| 661 | finally: | ||
| 662 | # Always stop | ||
| 663 | registry.stop() | ||
diff --git a/tests/test_vdkr_registry.py b/tests/test_vdkr_registry.py new file mode 100644 index 00000000..9073444d --- /dev/null +++ b/tests/test_vdkr_registry.py | |||
| @@ -0,0 +1,414 @@ | |||
| 1 | # SPDX-FileCopyrightText: Copyright (C) 2025 Bruce Ashfield | ||
| 2 | # | ||
| 3 | # SPDX-License-Identifier: MIT | ||
| 4 | """ | ||
| 5 | Tests for vdkr registry functionality. | ||
| 6 | |||
| 7 | These tests verify vdkr registry configuration and pulling from registries: | ||
| 8 | - CLI override: --registry <url> pull <image> | ||
| 9 | - Persistent config: vconfig registry <url> + pull <image> | ||
| 10 | - Config reset: vconfig registry --reset | ||
| 11 | - Image compound commands: image ls/rm/pull/inspect/tag | ||
| 12 | |||
| 13 | Note: Tests that require a running registry use mock endpoints or skip | ||
| 14 | if no registry is available. For full integration testing, start the | ||
| 15 | registry first: | ||
| 16 | $TOPDIR/container-registry/container-registry.sh start | ||
| 17 | |||
| 18 | Run with: | ||
| 19 | pytest tests/test_vdkr_registry.py -v --vdkr-dir /tmp/vcontainer | ||
| 20 | |||
| 21 | Run specific test class: | ||
| 22 | pytest tests/test_vdkr_registry.py::TestVconfigRegistry -v | ||
| 23 | """ | ||
| 24 | |||
| 25 | import pytest | ||
| 26 | import json | ||
| 27 | import subprocess | ||
| 28 | |||
| 29 | |||
| 30 | class TestVconfigRegistry: | ||
| 31 | """Test vconfig registry configuration commands.""" | ||
| 32 | |||
| 33 | def test_vconfig_registry_show_empty(self, vdkr): | ||
| 34 | """Test showing registry config when not set.""" | ||
| 35 | # Reset first to ensure clean state | ||
| 36 | vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 37 | |||
| 38 | result = vdkr.run("vconfig", "registry") | ||
| 39 | assert result.returncode == 0 | ||
| 40 | # Should show empty or "not set" message | ||
| 41 | output = result.stdout.strip() | ||
| 42 | assert output == "" or "not set" in output.lower() or "registry:" in output.lower() | ||
| 43 | |||
| 44 | def test_vconfig_registry_set(self, vdkr): | ||
| 45 | """Test setting registry configuration.""" | ||
| 46 | test_registry = "10.0.2.2:5000/test" | ||
| 47 | |||
| 48 | result = vdkr.run("vconfig", "registry", test_registry) | ||
| 49 | assert result.returncode == 0 | ||
| 50 | |||
| 51 | # Verify it was set | ||
| 52 | result = vdkr.run("vconfig", "registry") | ||
| 53 | assert result.returncode == 0 | ||
| 54 | assert test_registry in result.stdout | ||
| 55 | |||
| 56 | def test_vconfig_registry_reset(self, vdkr): | ||
| 57 | """Test resetting registry configuration.""" | ||
| 58 | # Set a value first | ||
| 59 | vdkr.run("vconfig", "registry", "10.0.2.2:5000/test") | ||
| 60 | |||
| 61 | # Reset it | ||
| 62 | result = vdkr.run("vconfig", "registry", "--reset") | ||
| 63 | assert result.returncode == 0 | ||
| 64 | |||
| 65 | # Verify it was reset | ||
| 66 | result = vdkr.run("vconfig", "registry") | ||
| 67 | assert result.returncode == 0 | ||
| 68 | # Should be empty after reset | ||
| 69 | output = result.stdout.strip() | ||
| 70 | assert "10.0.2.2:5000/test" not in output | ||
| 71 | |||
| 72 | def test_vconfig_show_all_includes_registry(self, vdkr): | ||
| 73 | """Test that vconfig (no args) shows registry in output.""" | ||
| 74 | result = vdkr.run("vconfig") | ||
| 75 | assert result.returncode == 0 | ||
| 76 | # Should list registry as one of the config keys | ||
| 77 | assert "registry" in result.stdout.lower() | ||
| 78 | |||
| 79 | |||
| 80 | class TestImageCompoundCommands: | ||
| 81 | """Test vdkr image compound commands. | ||
| 82 | |||
| 83 | These translate docker image <subcommand> to the appropriate docker command: | ||
| 84 | - image ls → images | ||
| 85 | - image rm → rmi | ||
| 86 | - image pull → pull (with registry transform) | ||
| 87 | - image inspect → inspect | ||
| 88 | - image tag → tag | ||
| 89 | - image prune → image prune | ||
| 90 | - image history → history | ||
| 91 | |||
| 92 | Note: These tests reset any registry config to ensure commands work with | ||
| 93 | unqualified image names. Registry-specific tests are in TestRegistryTransform. | ||
| 94 | """ | ||
| 95 | |||
| 96 | @pytest.fixture(autouse=True) | ||
| 97 | def setup_memres(self, memres_session): | ||
| 98 | """Ensure memres is running and registry config is reset.""" | ||
| 99 | self.vdkr = memres_session | ||
| 100 | self.vdkr.ensure_memres() | ||
| 101 | # Reset any baked-in or configured registry to test basic commands | ||
| 102 | self.vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 103 | |||
| 104 | def get_alpine_info(self): | ||
| 105 | """Get alpine image info (ref and ID). | ||
| 106 | |||
| 107 | Returns tuple of (full_ref, image_id) or (None, None) if not found. | ||
| 108 | """ | ||
| 109 | # Use raw 'images' to see what's actually stored | ||
| 110 | images_result = self.vdkr.run("images", check=False) | ||
| 111 | if images_result.returncode != 0: | ||
| 112 | return None, None | ||
| 113 | |||
| 114 | # Parse the images output to find alpine | ||
| 115 | for line in images_result.stdout.splitlines(): | ||
| 116 | if "alpine" in line.lower() and "REPOSITORY" not in line: | ||
| 117 | # Parse: REPOSITORY TAG IMAGE_ID CREATED SIZE | ||
| 118 | parts = line.split() | ||
| 119 | if len(parts) >= 3: | ||
| 120 | repo = parts[0] | ||
| 121 | tag = parts[1] | ||
| 122 | image_id = parts[2] | ||
| 123 | return f"{repo}:{tag}", image_id | ||
| 124 | return None, None | ||
| 125 | |||
| 126 | def test_image_ls(self): | ||
| 127 | """Test 'image ls' command.""" | ||
| 128 | result = self.vdkr.run("image", "ls") | ||
| 129 | assert result.returncode == 0 | ||
| 130 | # Should have header line | ||
| 131 | assert "REPOSITORY" in result.stdout or "IMAGE" in result.stdout | ||
| 132 | |||
| 133 | def ensure_alpine_and_get_info(self): | ||
| 134 | """Ensure alpine is present and return (ref, image_id).""" | ||
| 135 | # First check if alpine is already present | ||
| 136 | alpine_ref, alpine_id = self.get_alpine_info() | ||
| 137 | if alpine_ref: | ||
| 138 | return alpine_ref, alpine_id | ||
| 139 | |||
| 140 | # Try to pull alpine | ||
| 141 | result = self.vdkr.run("pull", "alpine:latest", timeout=300, check=False) | ||
| 142 | if result.returncode != 0: | ||
| 143 | return None, None | ||
| 144 | |||
| 145 | # Get the info again | ||
| 146 | return self.get_alpine_info() | ||
| 147 | |||
| 148 | @pytest.mark.network | ||
| 149 | def test_image_ls_with_filter(self): | ||
| 150 | """Test 'image ls' with filter. | ||
| 151 | |||
| 152 | After the transform fix, 'images alpine' should show alpine directly | ||
| 153 | without registry prefix transformation. | ||
| 154 | """ | ||
| 155 | alpine_ref, alpine_id = self.ensure_alpine_and_get_info() | ||
| 156 | if not alpine_ref: | ||
| 157 | pytest.skip("Could not get alpine image (network issue?)") | ||
| 158 | |||
| 159 | # Get the simple name for filtering (e.g., "alpine" from "alpine:latest") | ||
| 160 | simple_name = alpine_ref.split("/")[-1].split(":")[0] # "alpine" | ||
| 161 | |||
| 162 | # Filter should work with simple name after transform fix | ||
| 163 | result = self.vdkr.run("images", simple_name) | ||
| 164 | assert result.returncode == 0 | ||
| 165 | |||
| 166 | # Should show the alpine image (check for image ID to be robust) | ||
| 167 | if alpine_id not in result.stdout and "alpine" not in result.stdout: | ||
| 168 | # If filter doesn't work (old vdkr), at least verify full list works | ||
| 169 | full_result = self.vdkr.run("images") | ||
| 170 | assert alpine_id in full_result.stdout, f"Alpine ({alpine_id}) not in images" | ||
| 171 | pytest.skip("Filter transform not fixed yet - vdkr rebuild needed") | ||
| 172 | |||
| 173 | @pytest.mark.network | ||
| 174 | def test_image_pull(self): | ||
| 175 | """Test 'image pull' command.""" | ||
| 176 | # Remove if exists | ||
| 177 | self.vdkr.run("image", "rm", "-f", "busybox:latest", check=False) | ||
| 178 | |||
| 179 | # Pull via image command | ||
| 180 | result = self.vdkr.run("image", "pull", "busybox:latest", timeout=300, check=False) | ||
| 181 | if result.returncode != 0: | ||
| 182 | pytest.skip(f"Could not pull busybox (network issue?): {result.stderr}") | ||
| 183 | |||
| 184 | # Verify it appears in images | ||
| 185 | images = self.vdkr.run("image", "ls") | ||
| 186 | assert "busybox" in images.stdout | ||
| 187 | |||
| 188 | @pytest.mark.network | ||
| 189 | def test_image_inspect(self): | ||
| 190 | """Test 'image inspect' command.""" | ||
| 191 | alpine_ref, alpine_id = self.ensure_alpine_and_get_info() | ||
| 192 | if not alpine_id: | ||
| 193 | pytest.skip("Could not get alpine image (network issue?)") | ||
| 194 | |||
| 195 | # Use image ID to avoid registry transform | ||
| 196 | result = self.vdkr.run("image", "inspect", alpine_id) | ||
| 197 | assert result.returncode == 0 | ||
| 198 | |||
| 199 | # Should be valid JSON | ||
| 200 | data = json.loads(result.stdout) | ||
| 201 | assert isinstance(data, list) | ||
| 202 | assert len(data) > 0 | ||
| 203 | |||
| 204 | @pytest.mark.network | ||
| 205 | def test_image_history(self): | ||
| 206 | """Test 'image history' command.""" | ||
| 207 | alpine_ref, alpine_id = self.ensure_alpine_and_get_info() | ||
| 208 | if not alpine_id: | ||
| 209 | pytest.skip("Could not get alpine image (network issue?)") | ||
| 210 | |||
| 211 | # Use image ID to avoid registry transform | ||
| 212 | result = self.vdkr.run("image", "history", alpine_id) | ||
| 213 | assert result.returncode == 0 | ||
| 214 | # Should show history with IMAGE or CREATED columns | ||
| 215 | assert "IMAGE" in result.stdout or "CREATED" in result.stdout | ||
| 216 | |||
| 217 | @pytest.mark.network | ||
| 218 | def test_image_tag(self): | ||
| 219 | """Test 'image tag' command.""" | ||
| 220 | alpine_ref, alpine_id = self.ensure_alpine_and_get_info() | ||
| 221 | if not alpine_id: | ||
| 222 | pytest.skip("Could not get alpine image (network issue?)") | ||
| 223 | |||
| 224 | # Use image ID as source to avoid registry transform | ||
| 225 | result = self.vdkr.run("image", "tag", alpine_id, "my-test-alpine:v1") | ||
| 226 | assert result.returncode == 0 | ||
| 227 | |||
| 228 | # Verify the new tag exists | ||
| 229 | images = self.vdkr.run("image", "ls") | ||
| 230 | assert "my-test-alpine" in images.stdout | ||
| 231 | |||
| 232 | # Clean up using image ID of the new tag | ||
| 233 | self.vdkr.run("rmi", "my-test-alpine:v1", check=False) | ||
| 234 | |||
| 235 | @pytest.mark.network | ||
| 236 | def test_image_rm(self): | ||
| 237 | """Test 'image rm' command.""" | ||
| 238 | alpine_ref, alpine_id = self.ensure_alpine_and_get_info() | ||
| 239 | if not alpine_id: | ||
| 240 | pytest.skip("Could not get alpine image (network issue?)") | ||
| 241 | |||
| 242 | # Tag using image ID to create a removable image | ||
| 243 | result = self.vdkr.run("tag", alpine_id, "test-rm:latest", check=False) | ||
| 244 | if result.returncode != 0: | ||
| 245 | pytest.skip(f"Could not tag image: {result.stderr}") | ||
| 246 | |||
| 247 | # Remove it using rmi (not image rm) to avoid transform | ||
| 248 | result = self.vdkr.run("rmi", "test-rm:latest", check=False) | ||
| 249 | # Verify it succeeded or at least didn't crash | ||
| 250 | assert result.returncode == 0 or "No such image" in result.stdout | ||
| 251 | |||
| 252 | def test_image_prune(self): | ||
| 253 | """Test 'image prune' command.""" | ||
| 254 | # Prune dangling images (-f to skip confirmation) | ||
| 255 | result = self.vdkr.run("image", "prune", "-f") | ||
| 256 | assert result.returncode == 0 | ||
| 257 | |||
| 258 | def test_image_requires_subcommand(self): | ||
| 259 | """Test that 'image' without subcommand shows error.""" | ||
| 260 | result = self.vdkr.run("image", check=False) | ||
| 261 | assert result.returncode != 0 | ||
| 262 | assert "subcommand" in result.stderr.lower() or "requires" in result.stderr.lower() | ||
| 263 | |||
| 264 | |||
| 265 | class TestRegistryCLIOverride: | ||
| 266 | """Test --registry CLI flag for one-off registry usage. | ||
| 267 | |||
| 268 | Note: These tests require a running registry. They skip if no registry | ||
| 269 | is available. | ||
| 270 | """ | ||
| 271 | |||
| 272 | @pytest.fixture | ||
| 273 | def registry_url(self, request): | ||
| 274 | """Get registry URL from command line or environment, or skip.""" | ||
| 275 | url = request.config.getoption("--registry-url", default=None) | ||
| 276 | if url is None: | ||
| 277 | import os | ||
| 278 | url = os.environ.get("TEST_REGISTRY_URL") | ||
| 279 | if url is None: | ||
| 280 | pytest.skip("No registry URL provided (use --registry-url or TEST_REGISTRY_URL)") | ||
| 281 | return url | ||
| 282 | |||
| 283 | @pytest.mark.network | ||
| 284 | def test_registry_flag_pull(self, memres_session, registry_url): | ||
| 285 | """Test pulling with --registry flag.""" | ||
| 286 | vdkr = memres_session | ||
| 287 | vdkr.ensure_memres() | ||
| 288 | |||
| 289 | # Pull with explicit registry | ||
| 290 | result = vdkr.run("--registry", registry_url, "pull", "alpine", timeout=300, check=False) | ||
| 291 | # May succeed or fail depending on whether image exists in registry | ||
| 292 | # Just verify the command is accepted | ||
| 293 | assert "unknown flag" not in result.stderr.lower() | ||
| 294 | |||
| 295 | @pytest.mark.network | ||
| 296 | def test_registry_flag_run(self, memres_session, registry_url): | ||
| 297 | """Test run with --registry flag.""" | ||
| 298 | vdkr = memres_session | ||
| 299 | vdkr.ensure_memres() | ||
| 300 | |||
| 301 | # Try to run with explicit registry | ||
| 302 | result = vdkr.run("--registry", registry_url, "run", "--rm", "alpine", | ||
| 303 | "echo", "hello", timeout=300, check=False) | ||
| 304 | # Just verify the flag is accepted | ||
| 305 | assert "unknown flag" not in result.stderr.lower() | ||
| 306 | |||
| 307 | |||
| 308 | class TestRegistryPersistentConfig: | ||
| 309 | """Test persistent registry configuration with vconfig.""" | ||
| 310 | |||
| 311 | @pytest.fixture(autouse=True) | ||
| 312 | def reset_registry_config(self, vdkr): | ||
| 313 | """Reset registry config before and after each test.""" | ||
| 314 | vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 315 | yield | ||
| 316 | vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 317 | |||
| 318 | def test_config_persists_across_commands(self, vdkr): | ||
| 319 | """Test that registry config persists across multiple vdkr invocations.""" | ||
| 320 | test_registry = "10.0.2.2:5000/persistent-test" | ||
| 321 | |||
| 322 | # Set registry | ||
| 323 | vdkr.run("vconfig", "registry", test_registry) | ||
| 324 | |||
| 325 | # Verify in new command | ||
| 326 | result = vdkr.run("vconfig", "registry") | ||
| 327 | assert test_registry in result.stdout | ||
| 328 | |||
| 329 | # Verify in vconfig all | ||
| 330 | result = vdkr.run("vconfig") | ||
| 331 | assert test_registry in result.stdout | ||
| 332 | |||
| 333 | def test_pull_uses_config(self, memres_session): | ||
| 334 | """Test that pull command uses configured registry. | ||
| 335 | |||
| 336 | Note: This test verifies the configuration is passed to the VM. | ||
| 337 | It may fail if the registry doesn't have the image, but we can | ||
| 338 | check the error message to verify the registry was used. | ||
| 339 | """ | ||
| 340 | vdkr = memres_session | ||
| 341 | vdkr.ensure_memres() | ||
| 342 | |||
| 343 | # Set a fake registry | ||
| 344 | fake_registry = "10.0.2.2:9999/fake" | ||
| 345 | vdkr.run("vconfig", "registry", fake_registry) | ||
| 346 | |||
| 347 | # Try to pull - should fail because registry doesn't exist | ||
| 348 | # but error should reference the fake registry | ||
| 349 | result = vdkr.run("pull", "nonexistent-image", timeout=60, check=False) | ||
| 350 | |||
| 351 | # The important thing is that it tried to use our registry | ||
| 352 | # (connection refused or similar error indicates it tried) | ||
| 353 | assert result.returncode != 0 | ||
| 354 | # Error should indicate connection issue to our fake registry | ||
| 355 | |||
| 356 | |||
| 357 | class TestInsecureRegistry: | ||
| 358 | """Test --insecure-registry flag.""" | ||
| 359 | |||
| 360 | def test_insecure_registry_flag_accepted(self, vdkr): | ||
| 361 | """Test that --insecure-registry flag is accepted.""" | ||
| 362 | vdkr.ensure_memres() | ||
| 363 | |||
| 364 | # Just verify the flag is recognized | ||
| 365 | result = vdkr.run("--insecure-registry", "10.0.2.2:5000", "images", check=False) | ||
| 366 | assert "unknown flag" not in result.stderr.lower() | ||
| 367 | assert "unrecognized" not in result.stderr.lower() | ||
| 368 | |||
| 369 | def test_multiple_insecure_registries(self, vdkr): | ||
| 370 | """Test multiple --insecure-registry flags.""" | ||
| 371 | vdkr.ensure_memres() | ||
| 372 | |||
| 373 | result = vdkr.run( | ||
| 374 | "--insecure-registry", "10.0.2.2:5000", | ||
| 375 | "--insecure-registry", "10.0.2.2:5001", | ||
| 376 | "images", check=False | ||
| 377 | ) | ||
| 378 | assert "unknown flag" not in result.stderr.lower() | ||
| 379 | |||
| 380 | |||
| 381 | class TestRegistryTransform: | ||
| 382 | """Test image name transformation with registry prefix. | ||
| 383 | |||
| 384 | When a default registry is configured, unqualified image names | ||
| 385 | should be transformed to include the registry prefix. | ||
| 386 | """ | ||
| 387 | |||
| 388 | @pytest.fixture(autouse=True) | ||
| 389 | def reset_registry_config(self, vdkr): | ||
| 390 | """Reset registry config before and after each test.""" | ||
| 391 | vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 392 | yield | ||
| 393 | vdkr.run("vconfig", "registry", "--reset", check=False) | ||
| 394 | |||
| 395 | def test_qualified_names_not_transformed(self, memres_session): | ||
| 396 | """Test that fully qualified image names are not transformed.""" | ||
| 397 | vdkr = memres_session | ||
| 398 | vdkr.ensure_memres() | ||
| 399 | |||
| 400 | # Set a registry | ||
| 401 | vdkr.run("vconfig", "registry", "10.0.2.2:5000/test") | ||
| 402 | |||
| 403 | # Pull fully qualified image - should use docker.io, not our registry | ||
| 404 | result = vdkr.run("pull", "docker.io/library/alpine:latest", | ||
| 405 | timeout=300, check=False) | ||
| 406 | |||
| 407 | # If successful, alpine from docker.io should be present | ||
| 408 | # If failed, should NOT be a connection error to 10.0.2.2 | ||
| 409 | if result.returncode != 0: | ||
| 410 | # Should not have tried our fake registry | ||
| 411 | assert "10.0.2.2:5000/test" not in result.stderr | ||
| 412 | |||
| 413 | |||
| 414 | # Note: Registry options (--registry-url) are defined in conftest.py | ||
