Add path_prefix support for registry separation

Enables organizational separation within registry hosts by adding
optional path_prefix configuration. This supports common use cases
like Harbor projects, private registry namespaces, and organizational
hierarchies.

Key changes:
- Add path_prefix field to Registry class with automatic slash
  normalization
- Update DockerSyncImagesKeywords to construct URLs with path prefixes
- Enhance configuration documentation with path_prefix examples
- Add unit tests for path_prefix handling and URL construction

Examples:
- Harbor projects: "project-x/test" -> harbor.com/project-x/test/busybox
- Team namespaces: "team-a" -> registry.com/team-a/my-image

The path_prefix field is optional and trailing slashes are normalized
automatically, making configuration flexible while ensuring correct
URLs.

Change-Id: I4784bc10e61840901baeaef2b69f323956bc23c3
Signed-off-by: Andrew Vaillancourt <andrew.vaillancourt@windriver.com>
This commit is contained in:
Andrew Vaillancourt
2025-08-07 17:31:37 -04:00
parent 182109b14f
commit 54d11e5f39
8 changed files with 154 additions and 30 deletions

View File

@@ -35,7 +35,14 @@
// "manifest_registry" is set to null—for clarity and visibility.
//
// - "registries":
// A dictionary of registry definitions including URLs and credentials.
// A dictionary of registry definitions including URLs, credentials, and optional path prefixes.
// Each registry can define:
// - "path_prefix": Optional path prefix prepended to image names during sync.
// Enables organizational separation within the same registry host.
// Trailing slash is optional and will be normalized automatically.
// Examples:
// - Harbor projects: "project-x/test" -> harbor.com/project-x/test/busybox
// - Private registry namespaces: "team-a" -> registry.com/team-a/my-image
//
// ----------------------------------------------------------------------------
// Registry Resolution Behavior:
@@ -76,6 +83,9 @@
// 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.
// - "path_prefix" is optional and only used when the registry organizes images using
// path-based hierarchies (Harbor projects, private registry namespaces, etc.).
// Public registries like DockerHub, k8s.io typically don't need path prefixes.
// ============================================================================
{
@@ -100,8 +110,7 @@
"override": false,
},
"resources/image_manifests/stx-third-party-images.yaml": {
"manifest_registry": "null", // No manifest fallback; each image uses its "source_registry" or "default_source_registry"
"override": false,
"manifest_registry": null, // No manifest fallback; each image uses its "source_registry" or "default_source_registry"
},
// // Use Harbor as the default for images in this manifest that do not specify "source_registry"
// "resources/image_manifests/stx-sanity-images.yaml": {
@@ -111,7 +120,7 @@
// // 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,
// // "override": false, // Not needed when manifest_registry is null (nothing to override with)
// },
},
@@ -137,14 +146,35 @@
"password": "",
},
// Example entry for a private registry such as Harbor:
// "harbor": {
// "registry_name": "harbor",
// Examples of registries with path_prefix for organizational separation:
// Harbor with project-based organization
// "harbor_project_x": {
// "registry_name": "harbor_project_x",
// "registry_url": "harbor.example.org:5000",
// "path_prefix": "project-x/test",
// "user_name": "robot_user",
// "password": "robot_token",
// },
// Same Harbor host, different project
// "harbor_user": {
// "registry_name": "harbor_user",
// "registry_url": "harbor.example.org:5000",
// "path_prefix": "user-project", # Trailing slash is optional and will be normalized automatically
// "user_name": "harbor_username",
// "password": "harbor_password",
// },
// Private registry with namespace organization
// "private_team_a": {
// "registry_name": "private_team_a",
// "registry_url": "registry.company.com:5000",
// "path_prefix": "team-a/projects/", # Trailing slash is optional and will be normalized automatically
// "user_name": "team_user",
// "password": "team_token",
// },
"local_registry": {
"registry_name": "local_registry",
"registry_url": "registry.local:9001",

View File

@@ -49,6 +49,7 @@ class DockerConfig:
registry_url=registry_dict["registry_url"],
user_name=registry_dict["user_name"],
password=registry_dict["password"],
path_prefix=registry_dict.get("path_prefix"),
)
self.registry_list.append(reg)

View File

@@ -1,20 +1,22 @@
class Registry:
"""Represents a Docker registry configuration."""
def __init__(self, registry_name: str, registry_url: str, user_name: str, password: str):
def __init__(self, registry_name: str, registry_url: str, user_name: str, password: str, path_prefix: str = None):
"""
Initializes a Registry object.
Args:
registry_name (str): Logical name of the registry (e.g., "source_registry").
registry_url (str): Registry endpoint URL (e.g., "docker.io/starlingx").
registry_url (str): Registry endpoint URL (e.g., "docker.io").
user_name (str): Username for authenticating with the registry.
password (str): Password for authenticating with the registry.
path_prefix (str): Optional path prefix for registry projects (e.g., "project/namespace/").
"""
self.registry_name = registry_name
self.registry_url = registry_url
self.user_name = user_name
self.password = password
self.path_prefix = path_prefix or ""
def get_registry_name(self) -> str:
"""
@@ -52,20 +54,35 @@ class Registry:
"""
return self.password
def get_path_prefix(self) -> str:
"""
Returns the path prefix prepended to image names during sync operations.
The path prefix enables organizational separation within the same registry host,
such as Harbor projects or private registry namespaces.
Returns:
str: Path prefix (e.g., "project/namespace") or empty string for registries
like DockerHub that don't require path-based organization.
"""
return self.path_prefix
def __str__(self) -> str:
"""
Returns a human-readable string representation of the registry.
Returns:
str: Formatted string showing registry name and URL.
str: Formatted string showing registry name, URL, and path prefix if present.
"""
if self.path_prefix:
return f"{self.registry_name} ({self.registry_url}/{self.path_prefix})"
return f"{self.registry_name} ({self.registry_url})"
def __repr__(self) -> str:
"""
Returns the representation string of the registry.
Returns the representation string of the registry for debugging.
Returns:
str: Registry representation.
str: Registry representation showing constructor parameters (excluding credentials).
"""
return self.__str__()
return f"Registry(registry_name='{self.registry_name}', registry_url='{self.registry_url}', path_prefix='{self.path_prefix}')"

View File

@@ -108,7 +108,14 @@ class DockerSyncImagesKeywords(BaseKeyword):
source_registry = docker_config.get_registry(source_registry_name)
source_image = f"{source_registry.get_registry_url()}/{name}:{tag}"
registry_url = source_registry.get_registry_url()
path_prefix = source_registry.get_path_prefix()
if path_prefix:
# Normalize path_prefix to ensure proper slash formatting
normalized_prefix = path_prefix.strip("/") + "/"
source_image = f"{registry_url}/{normalized_prefix}{name}:{tag}"
else:
source_image = f"{registry_url}/{name}:{tag}"
target_image = f"{local_registry.get_registry_url()}/{name}:{tag}"
get_logger().log_info(f"Pulling {source_image}")
@@ -150,6 +157,7 @@ class DockerSyncImagesKeywords(BaseKeyword):
source_registry = docker_config.get_registry(source_registry_name)
source_url = source_registry.get_registry_url()
path_prefix = source_registry.get_path_prefix()
# Always try to remove these two references
refs = [
@@ -159,9 +167,14 @@ class DockerSyncImagesKeywords(BaseKeyword):
# Optionally add full source registry tag if not DockerHub
if "docker.io" not in source_url:
refs.insert(0, f"{source_url}/{image_name}:{image_tag}")
if path_prefix:
# Normalize path_prefix to ensure proper slash formatting
normalized_prefix = path_prefix.strip("/") + "/"
refs.insert(0, f"{source_url}/{normalized_prefix}{image_name}:{image_tag}")
else:
refs.insert(0, f"{source_url}/{image_name}:{image_tag}")
else:
get_logger().log_debug(f"Skipping full docker.io-prefixed tag for {source_url}/{image_name}:{image_tag}")
get_logger().log_debug(f"Skipping full docker.io-prefixed tag for {source_url}/{path_prefix}{image_name}:{image_tag}")
return refs

View File

@@ -18,11 +18,14 @@
# - Authentication details (username, password, etc.) are configured in
# `config/docker/files/default.json5` under the corresponding `registries` entry.
# - This file defaults to `config/docker/files/default.json5`, but can be overridden using `--docker_config_file`.
# - Registry resolution is handled dynamically via `ConfigurationManager`.
# - Resolution priority (from most to least specific):
# 1. `source_registry` field on the image entry (optional)
# 2. `manifest_registry_map` in `config/docker/files/default.json5`
# 3. `default_source_registry` in `config/docker/files/default.json5`
# - `source_registry` values must match a registry name defined in the Docker config file
# (e.g., "dockerhub", "k8s", "gcr" - not URLs like "docker.io")
#
# Registry resolution priority:
# 1. `manifest_registry_map` with `override: true` (uses registry specified in manifest mapping in config, ignores source_registry in this file)
# 2. `source_registry` field on individual image entry (if override=false or no manifest mapping exists, must match registry name in config)
# 3. `manifest_registry_map` with `override: false` (fallback for images without source_registry)
# 4. `default_source_registry` (final fallback)
# ------------------------------------------------------------------------------
images:

View File

@@ -9,11 +9,14 @@
# - Image names must include their full namespace (e.g., `starlingx/stx-platformclients`).
# - Registry URLs and credentials are not listed here. They are defined in:
# `config/docker/files/default.json5`
# - Registry resolution is handled dynamically via `ConfigurationManager`.
# - Resolution priority (from most to least specific):
# 1. `source_registry` field on the individual image entry (optional)
# 2. `manifest_registry_map` entry in `config/docker/files/default.json5`
# 3. `default_source_registry` in `config/docker/files/default.json5`
# - `source_registry` values must match a registry name defined in the Docker config file
# (e.g., "dockerhub", "k8s", "gcr" - not URLs like "docker.io")
#
# Registry resolution priority:
# 1. `manifest_registry_map` with `override: true` (uses registry specified in manifest mapping in config, ignores source_registry in this file)
# 2. `source_registry` field on individual image entry (if override=false or no manifest mapping exists, must match registry name in config)
# 3. `manifest_registry_map` with `override: false` (fallback for images without source_registry)
# 4. `default_source_registry` (final fallback)
# ------------------------------------------------------------------------------
images:
- name: "starlingx/stx-platformclients"

View File

@@ -13,11 +13,14 @@
# - Image names must include their full namespace (e.g., `google-samples/node-hello`).
# - Registry URLs and credentials are defined in:
# `config/docker/files/default.json5`
# - `source_registry` values must match a registry name defined in the Docker config file
# (e.g., "dockerhub", "k8s", "gcr" - not URLs like "docker.io")
#
# Registry resolution priority (from most to least specific):
# 1. `source_registry` field on the individual image entry (recommended)
# 2. `manifest_registry_map` in the Docker config
# 3. `default_source_registry` fallback
# Registry resolution priority:
# 1. `manifest_registry_map` with `override: true` (uses registry specified in manifest mapping in config, ignores source_registry in this file)
# 2. `source_registry` field on individual image entry (if override=false or no manifest mapping exists, must match registry name in config)
# 3. `manifest_registry_map` with `override: false` (fallback for images without source_registry)
# 4. `default_source_registry` (final fallback)
# ------------------------------------------------------------------------------
images:

View File

@@ -0,0 +1,54 @@
from config.docker.objects.registry import Registry
class TestRegistryPathPrefix:
"""Tests for Registry path_prefix handling and URL construction."""
def test_path_prefix_with_trailing_slash(self):
"""Test that path_prefix with trailing slash is returned as-is."""
registry = Registry(registry_name="test_registry", registry_url="harbor.example.com", user_name="user", password="pass", path_prefix="project-x/test/")
assert registry.get_path_prefix() == "project-x/test/"
def test_path_prefix_without_trailing_slash(self):
"""Test that path_prefix without trailing slash is returned as-is."""
registry = Registry(registry_name="test_registry", registry_url="harbor.example.com", user_name="user", password="pass", path_prefix="project-x/test")
assert registry.get_path_prefix() == "project-x/test"
def test_path_prefix_with_leading_slash(self):
"""Test that path_prefix with leading slash is returned as-is."""
registry = Registry(registry_name="test_registry", registry_url="harbor.example.com", user_name="user", password="pass", path_prefix="/project-x/test/")
assert registry.get_path_prefix() == "/project-x/test/"
def test_empty_path_prefix(self):
"""Test that empty path_prefix returns empty string."""
registry = Registry(registry_name="test_registry", registry_url="docker.io", user_name="user", password="pass", path_prefix="")
assert registry.get_path_prefix() == ""
def test_none_path_prefix(self):
"""Test that None path_prefix defaults to empty string."""
registry = Registry(registry_name="test_registry", registry_url="docker.io", user_name="user", password="pass")
assert registry.get_path_prefix() == ""
def test_url_construction_normalization(self):
"""Test URL construction with various path_prefix formats."""
test_cases = [
("project-x/test/", "harbor.com/project-x/test/busybox:1.0"),
("project-x/test", "harbor.com/project-x/test/busybox:1.0"),
("/project-x/test/", "harbor.com/project-x/test/busybox:1.0"),
("/project-x/test", "harbor.com/project-x/test/busybox:1.0"),
("", "harbor.com/busybox:1.0"),
]
for path_prefix, expected_url in test_cases:
registry = Registry(registry_name="test_registry", registry_url="harbor.com", user_name="user", password="pass", path_prefix=path_prefix)
# Simulate the URL construction logic from the sync method
registry_url = registry.get_registry_url()
prefix = registry.get_path_prefix()
if prefix:
normalized_prefix = prefix.strip("/") + "/"
actual_url = f"{registry_url}/{normalized_prefix}busybox:1.0"
else:
actual_url = f"{registry_url}/busybox:1.0"
assert actual_url == expected_url, f"Failed for path_prefix='{path_prefix}'"