Improve Docker registry resolution flexibility

Enhances the DockerConfig design to support more flexible registry
resolution for shared manifests across different environments,
including air-gapped or network-restricted deployments.

Key improvements:
- Refactors manifest_registry_map to support structured entries with
  manifest_registry and override fields.
- Implements clear resolution precedence (per-image, per-manifest,
  global default) via get_effective_source_registry_name().
- Enables declarative overrides so the same manifest files can be
  reused unchanged, while different environments can redirect all
  image sources through configuration alone.
- Allows mixing images with explicit source_registry fields alongside
  manifest-level defaults in the same YAML manifest.
- Adds targeted unit tests for DockerConfig to validate resolution
  behavior and enforce JSON5 schema constraints.

For example, users can share manifests that reference DockerHub, GCR,
or K8s images, and centrally configure all images to be pulled from
an internal mirror registry (e.g., Harbor) without modifying the
manifests themselves.

Change-Id: I9bbd1fe6a23bfa9afada2e4c54399572e1be0ddc
Signed-off-by: Andrew Vaillancourt <andrew.vaillancourt@windriver.com>
This commit is contained in:
Andrew Vaillancourt
2025-07-04 19:24:51 -04:00
parent ab7609afed
commit f5900942e3
6 changed files with 444 additions and 68 deletions

View File

@@ -1,32 +1,82 @@
// Example Docker registry configuration.
// ============================================================================
// Docker Registry Configuration
// ============================================================================
//
// This file is used as the default by ConfigurationManager.get_docker_config(),
// unless overridden via the --docker_config_file CLI option or programmatically.
// This file defines how Docker images are pulled from source registries and
// pushed to the local StarlingX registry for testing.
//
// Registry endpoints, credentials, and image manifest paths can be overridden at runtime
// using --docker_config_file or set programmatically via ConfigurationManager.
// Usage:
// - This file is used as the default by ConfigurationManager.get_docker_config(),
// unless overridden via the --docker_config_file CLI option.
//
// - Registry endpoints, credentials, and manifest paths can be customized by
// editing this file or providing an alternate JSON5 file via --docker_config_file.
//
// ----------------------------------------------------------------------------
// Fields:
// ----------------------------------------------------------------------------
// - "default_source_registry":
// The global fallback registry name if no per-image or manifest mapping applies.
//
// - "image_manifest_files":
// A list of YAML manifest files describing images, tags, and optional
// source registries. This allows modular organization of test images
// by domain, scenario, or team ownership.
//
// - "manifest_registry_map":
// Optional mapping of manifest filenames to registry resolution rules.
// Each entry can define:
// - "manifest_registry": A registry to apply to all images in this manifest
// if per-image definitions are not used.
// - "override": If true, all images in the manifest use "manifest_registry"
// regardless of any "source_registry" specified per image.
//
// Although per-manifest mapping is optional, it is encouraged—even if
// "manifest_registry" is set to null—for clarity and visibility.
//
// - "registries":
// A dictionary of registry definitions including URLs and credentials.
//
// ----------------------------------------------------------------------------
// Registry Resolution Behavior:
// - The "source_registry" field (if present) on an individual image in the manifest takes highest precedence.
// This allows different images within the same manifest to pull from different source registries.
// - Use "manifest_registry_map" to override the source registry per manifest.
// - Use "default_source_registry" as a global fallback if neither of the above is set.
// This is useful when all your images come from a single upstream source like DockerHub.
// The resolution priority is:
// 1. source_registry (per image)
// 2. manifest_registry_map (per manifest)
// 3. default_source_registry (global fallback)
// ----------------------------------------------------------------------------
// For each image, the registry is resolved in this order:
//
// 1) If a manifest entry exists in "manifest_registry_map":
// - If "override" is true:
// The "manifest_registry" is always used for all images.
// - If "override" is false:
// a. If the image defines "source_registry", that is used.
// b. Otherwise, if "manifest_registry" is defined (not null), it is used.
// c. Otherwise, "default_source_registry" is used.
//
// 2) If the manifest is listed in "image_manifest_files" but does not have a corresponding
// entry in "manifest_registry_map":
// - If the image defines "source_registry", that is used.
// - Otherwise, "default_source_registry" is used.
//
// ----------------------------------------------------------------------------
// Rationale:
// ----------------------------------------------------------------------------
// This design allows declarative, centralized control over where images are
// pulled from without requiring edits to the manifests themselves. It enables:
//
// - Reusing the same manifests across environments.
// - Overriding all sources (e.g., to pull from an internal or mirrored registry).
// - Per-image flexibility for mixed-registry scenarios.
// - Simplified configuration for air-gapped or network-restricted deployments.
//
// ----------------------------------------------------------------------------
// Notes:
// ----------------------------------------------------------------------------
// - Each registry must define a unique "registry_name", which acts as a logical key.
// This is referenced by:
// * the "source_registry" field in image manifests
// * the "manifest_registry_map" in this config file
// * the "default_source_registry" fallback below
//
// - "image_manifest_files" may include one or more YAML files.
// - Each image listed in a manifest is pulled from its resolved source registry
// and pushed into the "local_registry" defined below.
// This key is referenced in "manifest_registry_map", per-image "source_registry",
// and "default_source_registry".
// - Public registries (e.g., DockerHub, k8s, GCR) typically do not require credentials.
// Use empty strings for "user_name" and "password" in these cases.
// - Private registries or internal mirrors (including "local_registry") must be configured
// with valid credentials if authentication is required.
// ============================================================================
{
"default_source_registry": "dockerhub",
@@ -39,9 +89,25 @@
],
"manifest_registry_map": {
"resources/image_manifests/stx-test-images.yaml": "dockerhub",
// "resources/image_manifests/stx-test-images-invalid.yaml": "dockerhub",
// "resources/image_manifests/harbor-test-images.yaml": "harbor",
// Force all images in this manifest to come from DockerHub
"resources/image_manifests/stx-test-images.yaml": {
"manifest_registry": "dockerhub",
"override": true,
},
"resources/image_manifests/stx-test-images-invalid.yaml": {
"manifest_registry": "dockerhub",
"override": false,
},
// // Use Harbor as the default for images in this manifest that do not specify "source_registry"
// "resources/image_manifests/stx-sanity-images.yaml": {
// "manifest_registry": "harbor",
// "override": false,
// },
// // No manifest fallback is defined; each image will use its "source_registry" if set, or "default_source_registry".
// "resources/stx-networking-images.yaml": {
// "manifest_registry": null,
// "override": false,
// },
},
"registries": {
@@ -52,13 +118,27 @@
"password": "",
},
"k8s": {
"registry_name": "k8s",
"registry_url": "registry.k8s.io",
"user_name": "",
"password": "",
},
"gcr": {
"registry_name": "gcr",
"registry_url": "gcr.io",
"user_name": "",
"password": "",
},
// Example entry for a private registry such as Harbor:
// "harbor": {
// "registry_name": "harbor",
// "registry_url": "harbor.example.org:5000",
// "user_name": "robot_user",
// "password": "robot_token",
// }
// },
"local_registry": {
"registry_name": "local_registry",
@@ -66,5 +146,5 @@
"user_name": "test_user",
"password": "test_password",
},
}
}
},
}

View File

@@ -33,9 +33,23 @@ class DockerConfig:
print(f"Could not find the Docker config file: {config}")
raise
# Validate manifest_registry_map entries
manifest_map = self._config_dict.get("manifest_registry_map", {})
for manifest_path, entry in manifest_map.items():
if isinstance(entry, dict):
override = entry.get("override", False)
manifest_registry = entry.get("manifest_registry", None)
if override and manifest_registry is None:
raise ValueError(f"Invalid manifest_registry_map entry for '{manifest_path}': " "override=true requires 'manifest_registry' to be set (not null).")
for registry_key in self._config_dict.get("registries", {}):
registry_dict = self._config_dict["registries"][registry_key]
reg = Registry(registry_name=registry_dict["registry_name"], registry_url=registry_dict["registry_url"], user_name=registry_dict["user_name"], password=registry_dict["password"])
reg = Registry(
registry_name=registry_dict["registry_name"],
registry_url=registry_dict["registry_url"],
user_name=registry_dict["user_name"],
password=registry_dict["password"],
)
self.registry_list.append(reg)
def get_registry(self, registry_name: str) -> Registry:
@@ -82,24 +96,12 @@ class DockerConfig:
"""
return self._config_dict.get("default_source_registry", "")
def get_registry_for_manifest(self, manifest_path: str) -> str:
"""
Returns the default registry name for a given manifest path, if defined.
Args:
manifest_path (str): Full relative path to the manifest file.
Returns:
str: Logical registry name (e.g., 'dockerhub'), or empty string.
"""
return self._config_dict.get("manifest_registry_map", {}).get(manifest_path, "")
def get_manifest_registry_map(self) -> dict:
"""
Returns the mapping of manifest file paths to registry names.
Returns the mapping of manifest file paths to registry definitions.
Returns:
dict: Mapping of manifest file path -> logical registry name.
dict: Mapping of manifest file path -> dict with 'manifest_registry' and 'override'.
"""
return self._config_dict.get("manifest_registry_map", {})
@@ -107,22 +109,49 @@ class DockerConfig:
"""
Resolves the source registry name for a given image using the following precedence:
1. The "source_registry" field in the image entry (if present).
2. A per-manifest registry mapping defined in "manifest_registry_map" in the config.
3. The global "default_source_registry" defined in the config.
1. If a manifest entry exists in "manifest_registry_map":
- If "override" is true, use the manifest's "manifest_registry" (must not be null).
- If "override" is false:
a. If the image has "source_registry", use it.
b. If the manifest's "manifest_registry" is set (not null), use it.
c. Otherwise, use "default_source_registry".
2. If no manifest entry exists:
- If the image has "source_registry", use it.
- Otherwise, use "default_source_registry".
Args:
image (dict): An image entry from the manifest.
manifest_filename (str): Filename of the manifest (e.g., 'stx-test-images.yaml').
manifest_filename (str): Filename of the manifest.
Returns:
str: The resolved logical registry name (e.g., 'dockerhub').
str: The resolved logical registry name.
Raises:
ValueError: If "override" is true but "manifest_registry" is null.
"""
manifest_map = self.get_manifest_registry_map()
manifest_entry = manifest_map.get(manifest_filename)
if manifest_entry:
manifest_registry = manifest_entry.get("manifest_registry")
override = manifest_entry.get("override", False)
if override:
if manifest_registry is None:
raise ValueError(f"Invalid manifest_registry_map entry for '{manifest_filename}': " "override=true requires 'manifest_registry' to be set (not null).")
return manifest_registry
# override == False
if "source_registry" in image:
return image["source_registry"]
if manifest_registry is not None:
return manifest_registry
return self.get_default_source_registry_name()
# No manifest entry
if "source_registry" in image:
return image["source_registry"]
manifest_map = self.get_manifest_registry_map()
if manifest_filename in manifest_map:
return manifest_map[manifest_filename]
return self.get_default_source_registry_name()

View File

@@ -42,22 +42,29 @@ class DockerSyncImagesKeywords(BaseKeyword):
Registry credentials and mappings are resolved using ConfigurationManager.get_docker_config(),
which loads config from `config/docker/files/default.json5` or a CLI override.
Registry resolution priority (from most to least specific):
1. "source_registry" field on the individual image entry (in the manifest)
2. "manifest_registry_map" entry matching the full manifest path (in config)
3. "default_source_registry" defined globally in config
Registry resolution behavior:
1) If a manifest entry exists in "manifest_registry_map":
- If "override" is true, all images in the manifest use the manifest "manifest_registry" (must be set).
- If "override" is false:
a. If an image defines "source_registry", it is used.
b. If no per-image "source_registry" is specified, but the manifest "manifest_registry" is set, it is used.
c. Otherwise, "default_source_registry" is used.
2) If no manifest entry exists:
- If an image defines "source_registry", it is used.
- Otherwise, "default_source_registry" is used.
Expected manifest format:
```yaml
images:
- name: "starlingx/test-image"
tag: "tag-x"
# Optional: source_registry: "dockerhub"
source_registry: "dockerhub" # Optional
```
Notes:
- Registry URLs and credentials must be defined in config, not in the manifest.
Any such values in the manifest are ignored.
Any such values in the manifest are ignored.
- Each image entry must include "name" and "tag".
Args:
@@ -69,13 +76,10 @@ class DockerSyncImagesKeywords(BaseKeyword):
"""
docker_config = ConfigurationManager.get_docker_config()
local_registry = docker_config.get_registry("local_registry")
default_registry_name = docker_config.get_default_source_registry_name()
with open(manifest_path, "r") as f:
manifest = yaml.safe_load(f)
manifest_registry_name = docker_config.get_registry_for_manifest(manifest_path)
if "images" not in manifest:
raise ValueError(f"Manifest at {manifest_path} is missing required 'images' key")
@@ -85,14 +89,12 @@ class DockerSyncImagesKeywords(BaseKeyword):
name = image["name"]
tag = image["tag"]
# Resolve source registry in order of precedence:
# 1) per-image override ("source_registry" in manifest)
# 2) per-manifest default (manifest_registry_map in config)
# 3) global fallback (default_source_registry in config)
source_registry_name = image.get("source_registry") or manifest_registry_name or default_registry_name
# Resolve the source registry using the config resolution precedence
source_registry_name = docker_config.get_effective_source_registry_name(image, manifest_path)
if not source_registry_name:
raise ValueError(f"Image '{name}:{tag}' has no 'source_registry' and no default_source_registry is set in config.")
raise ValueError(f"Image '{name}:{tag}' has no registry resolved (manifest: {manifest_path}).")
try:
source_registry = docker_config.get_registry(source_registry_name)

View File

@@ -62,7 +62,7 @@ def run_manifest_sync_test(request: FixtureRequest, manifest_filename: str) -> N
def cleanup():
get_logger().log_info(f"Cleaning up images listed in {manifest_filename}...")
ssh_connection = LabConnectionKeywords().get_active_controller_ssh()
DockerSyncImagesKeywords(ssh_connection).remove_images_from_manifest(manifest_path)
DockerSyncImagesKeywords(ssh_connection).remove_images_from_manifest(manifest_path=manifest_path)
request.addfinalizer(cleanup)

View File

@@ -0,0 +1,3 @@
"""
Unit tests for the DockerConfig module.
"""

View File

@@ -0,0 +1,262 @@
import pytest
from config.docker.objects.docker_config import DockerConfig
def test_valid_json5_parses_and_getters(tmp_path):
"""Verifies DockerConfig loads JSON5 and retrieves default registry and manifests."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": ["dummy.yaml"],
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
assert config.get_default_source_registry_name() == "dockerhub"
assert config.get_image_manifest_files() == ["dummy.yaml"]
reg = config.get_registry("dockerhub")
assert reg.get_registry_name() == "dockerhub"
assert reg.get_registry_url() == "docker.io"
def test_missing_config_file_raises():
"""Verifies FileNotFoundError is raised for a nonexistent config file."""
with pytest.raises(FileNotFoundError):
DockerConfig("nonexistent.json5")
def test_get_registry_invalid_raises(tmp_path):
"""Verifies ValueError is raised when an unknown registry name is requested."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
with pytest.raises(ValueError, match="No registry with the name 'invalid' was found"):
config.get_registry("invalid")
def test_resolve_override_true_manifest_registry(tmp_path):
"""Verifies override=true returns the manifest manifest_registry."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"manifest_registry_map": {
"file.yaml": {
"manifest_registry": "harbor",
"override": true
}
},
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
},
"harbor": {
"registry_name": "harbor",
"registry_url": "harbor.local",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
result = config.get_effective_source_registry_name({}, "file.yaml")
assert result == "harbor"
def test_resolve_override_false_per_image(tmp_path):
"""Verifies override=false uses per-image source_registry when present."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"manifest_registry_map": {
"file.yaml": {
"manifest_registry": "harbor",
"override": false
}
},
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
},
"harbor": {
"registry_name": "harbor",
"registry_url": "harbor.local",
"user_name": "",
"password": ""
},
"gcr": {
"registry_name": "gcr",
"registry_url": "gcr.io",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
result = config.get_effective_source_registry_name({"source_registry": "gcr"}, "file.yaml")
assert result == "gcr"
def test_resolve_override_false_manifest_registry(tmp_path):
"""Verifies override=false uses manifest manifest_registry if no per-image source_registry."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"manifest_registry_map": {
"file.yaml": {
"manifest_registry": "harbor",
"override": false
}
},
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
},
"harbor": {
"registry_name": "harbor",
"registry_url": "harbor.local",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
result = config.get_effective_source_registry_name({}, "file.yaml")
assert result == "harbor"
def test_resolve_no_manifest_entry_per_image(tmp_path):
"""Verifies resolution uses per-image source_registry when no manifest entry exists."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
},
"gcr": {
"registry_name": "gcr",
"registry_url": "gcr.io",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
result = config.get_effective_source_registry_name({"source_registry": "gcr"}, "unknown.yaml")
assert result == "gcr"
def test_resolve_no_manifest_entry_default(tmp_path):
"""Verifies resolution falls back to default registry if no manifest entry or per-image source_registry."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
}
}
}
"""
)
config = DockerConfig(str(config_file))
result = config.get_effective_source_registry_name({}, "unknown.yaml")
assert result == "dockerhub"
def test_override_true_with_null_registry_fails_in_init(tmp_path):
"""Verifies ValueError is raised during init if override=true and manifest_registry is null."""
config_file = tmp_path / "docker_config.json5"
config_file.write_text(
"""
{
"default_source_registry": "dockerhub",
"image_manifest_files": [],
"manifest_registry_map": {
"some.yaml": {
"manifest_registry": null,
"override": true
}
},
"registries": {
"dockerhub": {
"registry_name": "dockerhub",
"registry_url": "docker.io",
"user_name": "",
"password": ""
}
}
}
"""
)
with pytest.raises(ValueError, match="override=true requires 'manifest_registry' to be set"):
DockerConfig(str(config_file))