summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBruce Ashfield <bruce.ashfield@gmail.com>2026-01-12 16:14:00 +0000
committerBruce Ashfield <bruce.ashfield@gmail.com>2026-01-21 18:00:26 -0500
commit7418693e425d3355533594481fc224732dd154f5 (patch)
tree7e24ede1814bad1caa3e2addb81d7d6780d5bff8
parent65cebeda3a2eda72802a46949601ba3e021a0db6 (diff)
downloadmeta-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.bb8
-rw-r--r--recipes-containers/container-registry/docker-registry-config.bb7
-rw-r--r--tests/conftest.py19
-rw-r--r--tests/test_container_registry_script.py663
-rw-r--r--tests/test_vdkr_registry.py414
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
231def _cleanup_stale_test_state(): 250def _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"""
5Tests for container-registry.sh helper script.
6
7These 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
15Prerequisites:
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
22Run with:
23 pytest tests/test_container_registry_script.py -v
24
25Run with specific registry script:
26 pytest tests/test_container_registry_script.py -v \\
27 --registry-script /path/to/container-registry.sh
28
29Environment variables:
30 CONTAINER_REGISTRY_SCRIPT: Path to the registry script
31 TOPDIR: Yocto build directory (script at $TOPDIR/container-registry/)
32"""
33
34import pytest
35import subprocess
36import os
37import time
38from 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")
46def 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")
95def skip_network(request):
96 """Check if network tests should be skipped."""
97 return request.config.getoption("--skip-registry-network", default=False)
98
99
100class 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")
195def registry(registry_script):
196 """Create a RegistryScriptRunner instance."""
197 return RegistryScriptRunner(registry_script)
198
199
200@pytest.fixture(scope="module")
201def 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
223class 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
248class 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
324class 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
353class 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
417class 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
481class 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
546class 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
574class 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
611class 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"""
5Tests for vdkr registry functionality.
6
7These 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
13Note: Tests that require a running registry use mock endpoints or skip
14if no registry is available. For full integration testing, start the
15registry first:
16 $TOPDIR/container-registry/container-registry.sh start
17
18Run with:
19 pytest tests/test_vdkr_registry.py -v --vdkr-dir /tmp/vcontainer
20
21Run specific test class:
22 pytest tests/test_vdkr_registry.py::TestVconfigRegistry -v
23"""
24
25import pytest
26import json
27import subprocess
28
29
30class 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
80class 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
265class 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
308class 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
357class 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
381class 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