This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 56237588cb0b CAMEL-26492: check multi-arch manifest before proposing
container upgrades (#29149)
56237588cb0b is described below
commit 56237588cb0b30684388f1944335bcd8f4d4c58c
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Nov 6 10:28:07 2026 +0100
CAMEL-26492: check multi-arch manifest before proposing container upgrades
(#29149)
* CAMEL-26492: check multi-arch manifest before proposing container upgrades
The container version checker proposed upgrades even when the new image
tag lacked linux/s390x or linux/ppc64le support, breaking CI on those
architectures.
Changes:
- Add a module-level REQUIRED_ARCHITECTURES constant ['linux/s390x',
'linux/ppc64le']
- Add ContainerRegistryAPI.check_architectures(image, tag) that calls the
Docker Registry v2 manifest list endpoint (GET /v2/<repo>/manifests/<tag>
with Accept: application/vnd.docker.distribution.manifest.list.v2+json)
and returns the required architectures absent from the manifest.
- DockerHubAPI overrides check_architectures to obtain an anonymous Bearer
token from auth.docker.io before fetching the manifest.
- QuayAPI overrides check_architectures to use the quay.io/v2/... endpoint.
- ContainerImage gains a has_arch_override flag that is set when the
properties file already declares an arch-specific sibling (e.g.
keycloak.container.ppc64le). Such images are exempt from the check.
- VersionCheckResult gains an arch_missing field. When the top candidate
is missing required architectures, newer_versions is left empty so no
PR is opened for that image; arch_missing is populated for reporting.
- print_results shows a dedicated 'ARCH-INCOMPLETE IMAGES' section.
- output_json includes arch_missing in the JSON payload.
- Add --skip-arch-check CLI flag to bypass the verification.
- GHA workflow step summary reports the number of arch-blocked images.
- Add test_check_container_versions.py with 19 unit tests covering all
new code paths.
* CAMEL-26492: address review issues
* CAMEL-26492: Address review issues in multi-arch container version checker
- Fix mirror.gcr.io manifest checks: add GCRAPI.check_architectures override
that uses the image's original registry (mirror.gcr.io) instead of
normalising
to gcr.io, which returns 401 for public images
- Fix single-arch manifests: a response with no 'manifests' key (schema1 or
single-arch) now correctly reports all REQUIRED_ARCHITECTURES as missing,
rather than silently allowing the upgrade
- Detect skipITs.<arch> from component poms: add
_build_component_skip_its_map
that pre-scans all pom.xml files in the scan path at startup, building a
map
from camel-test-infra-X artifact IDs to their consumer component's skipped
arches. parse_properties_file merges these with the local pom walk-up
result.
- Skip arch-override sibling keys (e.g. keycloak.container.ppc64le) from
being
parsed as standalone images; they are arch-specific registry references,
not
independent images to be version-checked
- Remove duplicate 'import re as _re' inside functions; use the top-level
're'
- Tests: rename test_returns_empty_on_single_arch_manifest to
test_returns_missing_arches_on_single_arch_manifest and update assertion;
add GCRAPICheckArchitecturesTest (mirror.gcr.io, single-arch);
add ComponentSkipItsMapTest (3 cases for _build_component_skip_its_map)
Total: 28 → 33 unit tests
* CAMEL-26492: Address remaining review issues (skipITs intersection,
repo-root scan, unverified warning)
- Fix Issue 1: Build the component skipITs map from the repository root
(not just scan_path) so that component poms under components/ are found
even when scanning test-infra/. Added _find_repo_root() helper that walks
up to the directory containing components/ or .git. Also filter out
test-infra module poms that declare their own artifactId (not consumers).
- Fix Issue 2: Change _build_component_skip_its_map to use intersection
instead of union across consumer modules. An arch is now only exempted
when EVERY consumer pom that references a test-infra module skips it.
This prevents camel-test-infra-postgres from losing s390x just because
camel-pgvector skips it while camel-sql, camel-jta etc. still need it.
- Fix Issue 3: Add arch_unverified field to VersionCheckResult. When
check_architectures cannot verify (network error, auth failure, unknown
format), it now returns None instead of []. The upgrade is still proposed
but arch_unverified=true is set, which triggers a warning in the step
summary and human-readable output. Also added to JSON output.
Co-authored-by: Claude <[email protected]>
* CAMEL-26492: Address remaining review issues (camel-test-infra-all
exemption, offline tests, main guard)
* CAMEL-26492: use relative path parts to avoid false positives in checkout
dirs
* CAMEL-26492: fix RunCheckRepoRootTest docstring and remove tautological
assertion
The class docstring now says "via _find_repo_root" to match what it actually
tests (repo-root discovery when scan_path is test-infra). The description in
davsclaus's review pointed out it claimed to cover the camel-test-infra-all
exemption, which is covered by
ComponentSkipItsMapTest.test_test_infra_all_pom_is_ignored.
The final assertIn("linux/s390x", es_result.image.skipped_arches) only
verified the mocked return value (which always has s390x in skipped_arches),
so it could never catch a regression. The _component_skip_its_map assertions
above it are the meaningful checks.
---------
Co-authored-by: Claude <[email protected]>
---
.../check-container-versions.py | 537 +++++++++++++-
.../test_check_container_versions.py | 779 +++++++++++++++++++++
.github/workflows/check-container-versions.yml | 13 +
.github/workflows/pr-ci-scripts-validation.yml | 5 +
4 files changed, 1327 insertions(+), 7 deletions(-)
diff --git
a/.github/actions/check-container-upgrade/check-container-versions.py
b/.github/actions/check-container-upgrade/check-container-versions.py
index 9716313c92d7..1889df67c8eb 100755
--- a/.github/actions/check-container-upgrade/check-container-versions.py
+++ b/.github/actions/check-container-upgrade/check-container-versions.py
@@ -21,7 +21,10 @@
Container Version Checker for Apache Camel Test Infrastructure
This script scans all container.properties files in the test-infra modules and
checks
-for newer versions of container images in their respective registries.
+for newer versions of container images in their respective registries. For
images
+without architecture-specific overrides, it also verifies that the new version
is
+available as a multi-arch manifest for the required architectures (linux/s390x
and
+linux/ppc64le) before proposing an upgrade.
Supported Registries:
- Docker Hub (docker.io, registry-1.docker.io)
@@ -87,6 +90,7 @@ Options:
--json Output results in JSON format
--check-prereleases Include pre-release versions in checks
--registry-timeout Registry API timeout in seconds (default: 30)
+ --skip-arch-check Skip multi-arch manifest verification
--help, -h Show this help message
Dependencies:
@@ -204,6 +208,9 @@ class ContainerImage:
version_include: List[str] = None # Whitelist: version must contain one
of these words
version_exclude: List[str] = None # Blacklist: version must not contain
any of these words
version_freeze_major: bool = False # Lock to same major version when True
+ has_arch_override: bool = False # True when arch-specific properties
exist for this image
+ arch_overrides: List[str] = None # Specific arches that have override
properties (e.g. ['linux/ppc64le'])
+ skipped_arches: List[str] = None # Arches skipped by skipITs.* in the
module pom.xml
def __post_init__(self):
"""Initialize default values for optional fields."""
@@ -211,6 +218,10 @@ class ContainerImage:
self.version_include = []
if self.version_exclude is None:
self.version_exclude = []
+ if self.arch_overrides is None:
+ self.arch_overrides = []
+ if self.skipped_arches is None:
+ self.skipped_arches = []
# Infer structural pattern from current version (not a dataclass
field, excluded from asdict)
self._version_pattern = infer_version_pattern(self.current_version)
@@ -274,6 +285,16 @@ class VersionCheckResult:
newer_versions: List[str]
is_latest: bool
error: Optional[str] = None
+ arch_missing: Optional[List[str]] = None # Architectures absent from the
manifest list of latest_version
+ arch_unverified: bool = False # True when the multi-arch check could not
be performed (network error, unknown format)
+
+ def __post_init__(self):
+ if self.arch_missing is None:
+ self.arch_missing = []
+
+# Architectures that must be present in a multi-arch manifest before proposing
an upgrade.
+# Images with explicit arch-specific property overrides are exempt from this
check.
+REQUIRED_ARCHITECTURES = ['linux/s390x', 'linux/ppc64le']
class ContainerRegistryAPI:
"""Base class for container registry API interactions."""
@@ -297,9 +318,150 @@ class ContainerRegistryAPI:
return "registry-1.docker.io"
return registry
+ def _repo_name(self, image: ContainerImage) -> str:
+ """Build the repository path (namespace/name or just name)."""
+ if image.namespace:
+ return f"{image.namespace}/{image.name}"
+ return image.name
+
+ @staticmethod
+ def _parse_manifest_list(data: dict) -> Optional[List[str]]:
+ """Return present os/arch strings from a manifest-list payload, or
None for single-arch.
+
+ Returns a list when the payload is a manifest list (OCI image index or
+ Docker manifest list), or None when it is a single-arch manifest (no
+ ``manifests`` key, or a schema1 manifest with a top-level
``architecture``
+ field). The caller treats None as "single-arch / unverifiable".
+ """
+ manifests = data.get('manifests', [])
+ if not manifests:
+ return None
+ return [
+ f"{m.get('platform', {}).get('os', '')}/{m.get('platform',
{}).get('architecture', '')}"
+ for m in manifests
+ ]
+
+ def _fetch_manifest(self, url: str, token: str = '') -> Optional[dict]:
+ """GET a manifest URL with the multi-arch Accept header and optional
Bearer token.
+
+ Returns the parsed JSON on HTTP 200, None otherwise (non-200 or
network error).
+ """
+ headers = {
+ 'Accept': (
+ 'application/vnd.docker.distribution.manifest.list.v2+json,'
+ 'application/vnd.oci.image.index.v1+json'
+ )
+ }
+ if token:
+ headers['Authorization'] = f"Bearer {token}"
+ try:
+ response = self.session.get(url, headers=headers,
timeout=self.timeout)
+ if response.status_code == 200:
+ return response.json()
+ # Some registries (ghcr.io, docker.elastic.co) return 401 and a
+ # Www-Authenticate challenge even for public images. Attempt a
+ # token exchange using the challenge header before giving up.
+ if response.status_code == 401:
+ www_auth = response.headers.get('Www-Authenticate', '')
+ bearer_token = self._bearer_token_from_challenge(www_auth)
+ if bearer_token:
+ headers['Authorization'] = f"Bearer {bearer_token}"
+ retry = self.session.get(url, headers=headers,
timeout=self.timeout)
+ if retry.status_code == 200:
+ return retry.json()
+ return None
+ except Exception:
+ return None
+
+ def _bearer_token_from_challenge(self, www_auth: str) -> str:
+ """Parse a Bearer Www-Authenticate challenge and fetch an anonymous
token.
+
+ Handles the form:
+ Bearer realm="https://...",service="...",scope="..."
+
+ Returns the token string, or '' on failure.
+ """
+ if not www_auth.lower().startswith('bearer '):
+ return ''
+ try:
+ realm = re.search(r'realm="([^"]+)"', www_auth)
+ service = re.search(r'service="([^"]+)"', www_auth)
+ scope = re.search(r'scope="([^"]+)"', www_auth)
+ if not realm:
+ return ''
+ params = {}
+ if service:
+ params['service'] = service.group(1)
+ if scope:
+ params['scope'] = scope.group(1)
+ token_resp = self.session.get(realm.group(1), params=params,
timeout=self.timeout)
+ if token_resp.status_code == 200:
+ return token_resp.json().get('token', '')
+ except Exception:
+ pass
+ return ''
+
+ def check_architectures(self, image: ContainerImage, tag: str) ->
Optional[List[str]]:
+ """Return the list of REQUIRED_ARCHITECTURES that are absent from the
manifest for *tag*.
+
+ Uses the Docker Registry v2 manifest list API
+ (``GET /v2/<repo>/manifests/<tag>`` with
+ ``Accept: application/vnd.docker.distribution.manifest.list.v2+json``
+ or its OCI equivalent). Returns an empty list when all required
+ architectures are present. Returns ``None`` when the check cannot be
+ performed (network error, unrecognised format, non-200 after token
+ retry). Returns the full set of required arches when the response is
+ a single-arch manifest (no ``manifests`` key), because a single-arch
+ image is by definition not multi-arch.
+ """
+ try:
+ registry = self.normalize_registry_url(image.registry)
+ repo = self._repo_name(image)
+ url = f"https://{registry}/v2/{repo}/manifests/{tag}"
+ data = self._fetch_manifest(url)
+ if data is None:
+ # Cannot verify (network error, 401 without challenge, …).
+ return None
+ present = self._parse_manifest_list(data)
+ if present is None:
+ # Single-arch manifest: the image is definitely not multi-arch.
+ return list(REQUIRED_ARCHITECTURES)
+ return [arch for arch in REQUIRED_ARCHITECTURES if arch not in
present]
+ except Exception:
+ # Unexpected format or error — cannot verify.
+ return None
+
class DockerHubAPI(ContainerRegistryAPI):
"""Docker Hub registry API implementation."""
+ def check_architectures(self, image: ContainerImage, tag: str) ->
Optional[List[str]]:
+ """Check architectures via registry-1.docker.io, obtaining a Bearer
token first."""
+ try:
+ if not image.namespace or image.namespace == "library":
+ repo_name = f"library/{image.name}"
+ else:
+ repo_name = f"{image.namespace}/{image.name}"
+
+ # Obtain an anonymous Bearer token for the manifest scope
+ token_url = (
+ f"https://auth.docker.io/token"
+
f"?service=registry.docker.io&scope=repository:{repo_name}:pull"
+ )
+ token_resp = self.session.get(token_url, timeout=self.timeout)
+ token = token_resp.json().get('token', '') if
token_resp.status_code == 200 else ''
+
+ url =
f"https://registry-1.docker.io/v2/{repo_name}/manifests/{tag}"
+ data = self._fetch_manifest(url, token=token)
+ if data is None:
+ return None
+ present = self._parse_manifest_list(data)
+ if present is None:
+ # Single-arch manifest: the image is not multi-arch.
+ return list(REQUIRED_ARCHITECTURES)
+ return [arch for arch in REQUIRED_ARCHITECTURES if arch not in
present]
+ except Exception:
+ return None
+
def get_available_versions(self, image: ContainerImage) -> List[str]:
"""Get available versions from Docker Hub."""
try:
@@ -345,6 +507,22 @@ class DockerHubAPI(ContainerRegistryAPI):
class QuayAPI(ContainerRegistryAPI):
"""Quay.io registry API implementation."""
+ def check_architectures(self, image: ContainerImage, tag: str) ->
Optional[List[str]]:
+ """Check architectures using the Quay.io distribution-spec manifest
endpoint."""
+ try:
+ repo_name = self._repo_name(image)
+ url = f"https://quay.io/v2/{repo_name}/manifests/{tag}"
+ data = self._fetch_manifest(url)
+ if data is None:
+ return None
+ present = self._parse_manifest_list(data)
+ if present is None:
+ # Single-arch manifest: the image is not multi-arch.
+ return list(REQUIRED_ARCHITECTURES)
+ return [arch for arch in REQUIRED_ARCHITECTURES if arch not in
present]
+ except Exception:
+ return None
+
def get_available_versions(self, image: ContainerImage) -> List[str]:
"""Get available versions from Quay.io."""
try:
@@ -385,6 +563,26 @@ class QuayAPI(ContainerRegistryAPI):
class GCRAPI(ContainerRegistryAPI):
"""Google Container Registry API implementation."""
+ def check_architectures(self, image: ContainerImage, tag: str) ->
Optional[List[str]]:
+ """Check architectures using the original registry URL.
+
+ ``mirror.gcr.io`` answers manifest requests anonymously (200) while
+ ``gcr.io`` returns 401 for public images. Use the image's registry
as-is
+ (i.e. do NOT normalise) so ``mirror.gcr.io`` manifests are reachable.
+ """
+ try:
+ repo = self._repo_name(image)
+ url = f"https://{image.registry}/v2/{repo}/manifests/{tag}"
+ data = self._fetch_manifest(url)
+ if data is None:
+ return None
+ present = self._parse_manifest_list(data)
+ if present is None:
+ return list(REQUIRED_ARCHITECTURES)
+ return [arch for arch in REQUIRED_ARCHITECTURES if arch not in
present]
+ except Exception:
+ return None
+
def get_available_versions(self, image: ContainerImage) -> List[str]:
"""Get available versions from GCR."""
try:
@@ -576,9 +774,14 @@ class ContainerVersionChecker:
def __init__(self,
include_prereleases: bool = False,
registry_timeout: int = 30,
- verbose: bool = False):
+ verbose: bool = False,
+ skip_arch_check: bool = False):
self.include_prereleases = include_prereleases
self.verbose = verbose
+ self.skip_arch_check = skip_arch_check
+ # Populated by run_check() before processing files; maps
+ # camel-test-infra-X → [skipped linux/arches] from component poms.
+ self._component_skip_its_map: Dict[str, List[str]] = {}
# Initialize registry APIs
self.registry_apis = {
@@ -732,6 +935,136 @@ class ContainerVersionChecker:
return sorted(properties_files)
+ @staticmethod
+ def _read_skip_its_from_pom(container_properties_path: str) -> List[str]:
+ """Return 'linux/<arch>' entries for arches that have
skipITs.<arch>=true in a pom.xml.
+
+ Walks up the directory tree from the container.properties file, finds
the
+ nearest pom.xml, and reads the ``skipITs.ppc64le`` / ``skipITs.s390x``
+ Maven properties. Returns a list of 'linux/ppc64le' and/or
'linux/s390x'
+ for each arch that is set to true. An empty list means nothing is
skipped
+ or the pom.xml could not be found/parsed.
+ """
+ SKIP_ITS_PLATFORMS = {
+ 'skipITs.ppc64le': 'linux/ppc64le',
+ 'skipITs.s390x': 'linux/s390x',
+ 'skipITs.aarch64': 'linux/arm64',
+ }
+ try:
+ path = Path(container_properties_path).resolve().parent
+ # Walk up at most 10 levels to find a pom.xml
+ for _ in range(10):
+ pom = path / 'pom.xml'
+ if pom.exists():
+ content = pom.read_text(encoding='utf-8', errors='replace')
+ skipped = []
+ for prop, platform in SKIP_ITS_PLATFORMS.items():
+ # Match <skipITs.ppc64le>true</skipITs.ppc64le> (with
any whitespace)
+ tag = prop.replace('.', r'\.')
+ m = re.search(rf'<{tag}>\s*(true)\s*</{tag}>',
content, re.IGNORECASE)
+ if m:
+ skipped.append(platform)
+ return skipped
+ parent = path.parent
+ if parent == path:
+ break
+ path = parent
+ except Exception:
+ pass
+ return []
+
+ @staticmethod
+ def _build_component_skip_its_map(scan_path: str) -> Dict[str, List[str]]:
+ """Build a mapping from test-infra artifact IDs to skipped
architectures.
+
+ Scans all pom.xml files in the scan_path that reference a
+ ``camel-test-infra-*`` dependency and reads their ``skipITs.ppc64le``
+ / ``skipITs.s390x`` properties. The result is a dict from artifact ID
+ (e.g. ``camel-test-infra-elasticsearch``) to a list of platform strings
+ (e.g. ``['linux/s390x']``).
+
+ The logic uses **intersection** across all consumer modules: an arch is
+ only considered skipped for a test-infra module when **every** consumer
+ pom that references that module sets ``skipITs.<arch>=true``. This
+ prevents exempting an arch just because one consumer skips it while
+ others still need it (e.g. ``camel-pgvector`` skips s390x but
+ ``camel-sql`` does not, so ``camel-test-infra-postgres`` should still
+ be checked for s390x).
+
+ This handles the case where the skipITs property lives in a *component*
+ pom (e.g. ``components/camel-elasticsearch-rest-client/pom.xml``)
rather
+ than in the test-infra module pom itself.
+ """
+ SKIP_ITS_PLATFORMS = {
+ 'skipITs.ppc64le': 'linux/ppc64le',
+ 'skipITs.s390x': 'linux/s390x',
+ 'skipITs.aarch64': 'linux/arm64',
+ }
+ # First pass: collect per-consumer skipped arches for each test-infra
module.
+ # consumers_map: {artifact_id: [set_of_skipped_arches_for_consumer_1,
…]}
+ consumers_map: Dict[str, List[set]] = {}
+ try:
+ base = Path(scan_path).resolve()
+ exclude_dirs = {'target', 'build', '.git', '__pycache__'}
+ for pom_file in base.rglob('pom.xml'):
+ rel_parts = pom_file.relative_to(base).parts
+ if any(e in rel_parts for e in exclude_dirs):
+ continue
+ # Skip test-infra module poms — they are NOT consumer poms.
+ # e.g. test-infra/camel-test-infra-all/pom.xml declares every
+ # camel-test-infra-* artifact but has no skipITs, which would
+ # veto every exemption via the intersection logic.
+ if any(part.startswith('camel-test-infra-') for part in
rel_parts):
+ continue
+ try:
+ content = pom_file.read_text(encoding='utf-8',
errors='replace')
+ except Exception:
+ continue
+ # Find all camel-test-infra-* artifact references in this pom
+ deps =
re.findall(r'<artifactId>(camel-test-infra-[^<]+)</artifactId>', content)
+ if not deps:
+ continue
+ # Identify the pom's own artifactId to filter it out: a
test-infra
+ # module pom declares its own artifactId which is NOT a
dependency
+ # reference. We detect this by looking for an <artifactId>
that
+ # appears directly after <project> or inside the top-level
block.
+ # As a heuristic, exclude artifacts whose name matches a
directory
+ # component of the pom's path (the module directory is usually
named
+ # after the artifactId).
+ pom_dir_name = pom_file.parent.name
+ # Filter: only keep deps that are actual dependency references,
+ # not the pom's own artifactId declaration.
+ consumer_deps = [d.strip() for d in deps if d.strip() !=
pom_dir_name]
+ if not consumer_deps:
+ continue
+ # Read skipped arches for this pom
+ skipped: set = set()
+ for prop, platform in SKIP_ITS_PLATFORMS.items():
+ tag = prop.replace('.', r'\.')
+ m = re.search(rf'<{tag}>\s*(true)\s*</{tag}>', content,
re.IGNORECASE)
+ if m:
+ skipped.add(platform)
+ for dep in consumer_deps:
+ dep = dep.strip()
+ if dep not in consumers_map:
+ consumers_map[dep] = []
+ consumers_map[dep].append(skipped)
+ except Exception:
+ pass
+
+ # Second pass: intersect — an arch is only skipped when ALL consumers
skip it.
+ result_map: Dict[str, List[str]] = {}
+ for artifact_id, consumer_sets in consumers_map.items():
+ if not consumer_sets:
+ continue
+ # Start with the first consumer's skipped set, intersect with the
rest
+ common = consumer_sets[0]
+ for s in consumer_sets[1:]:
+ common = common & s
+ if common:
+ result_map[artifact_id] = sorted(common)
+ return result_map
+
def parse_properties_file(self, file_path: str) -> List[ContainerImage]:
"""Parse a container.properties file and extract container images."""
images = []
@@ -761,6 +1094,51 @@ class ContainerVersionChecker:
properties[key] = value
+ # Determine which base properties have arch-specific overrides.
+ # An arch override is a property whose name matches
+ # <base>.{ppc64le,s390x,aarch64,amd64,...} (i.e. a known arch
suffix
+ # that makes the update-metadata-version.py skip it via
property_prefix()).
+ # Maps the arch suffix (e.g. 'ppc64le') to 'linux/ppc64le'.
+ ARCH_SUFFIX_TO_PLATFORM = {
+ 'ppc64le': 'linux/ppc64le',
+ 's390x': 'linux/s390x',
+ 'aarch64': 'linux/arm64',
+ 'arm64': 'linux/arm64',
+ 'amd64': 'linux/amd64',
+ 'arm': 'linux/arm',
+ }
+ # Collect base names → set of overridden linux platforms
+ keys_arch_override_platforms: Dict[str, List[str]] = {}
+ for key in properties:
+ for arch, platform in ARCH_SUFFIX_TO_PLATFORM.items():
+ if key.endswith(f'.{arch}'):
+ base = key[:-(len(arch) + 1)]
+ if base not in keys_arch_override_platforms:
+ keys_arch_override_platforms[base] = []
+ if platform not in keys_arch_override_platforms[base]:
+ keys_arch_override_platforms[base].append(platform)
+ break
+
+ # Read skipITs.<arch> from the nearest pom.xml in the same module
tree.
+ # e.g. test-infra/camel-test-infra-observability/pom.xml may set
+ # skipITs.ppc64le=true and skipITs.s390x=true to indicate those
arches
+ # are already known to be unsupported for this module's tests.
+ module_skipped_arches = self._read_skip_its_from_pom(file_path)
+
+ # Also check the component-level skipITs map, which captures cases
where
+ # skipITs.<arch> is defined in a component pom (e.g.
components/camel-elasticsearch-rest-client/pom.xml)
+ # rather than in the test-infra module pom itself. We identify the
+ # test-infra module by extracting its artifact ID from the path.
+ test_infra_module = None
+ for part in Path(file_path).parts:
+ if part.startswith('camel-test-infra-'):
+ test_infra_module = part
+ break
+ if test_infra_module and self._component_skip_its_map:
+ for arch in
self._component_skip_its_map.get(test_infra_module, []):
+ if arch not in module_skipped_arches:
+ module_skipped_arches.append(arch)
+
# Second pass: build ContainerImage objects
processed_keys = set()
@@ -769,6 +1147,18 @@ class ContainerVersionChecker:
if key in processed_keys or '.version.include' in key or
'.version.exclude' in key or '.version.freeze' in key:
continue
+ # Skip arch-override sibling keys (e.g.
keycloak.container.ppc64le):
+ # they point to arch-specific registries that are not
standalone images.
+ is_arch_override_key = False
+ for arch in ARCH_SUFFIX_TO_PLATFORM:
+ if key.endswith(f'.{arch}'):
+ base = key[:-(len(arch) + 1)]
+ if base in properties:
+ is_arch_override_key = True
+ break
+ if is_arch_override_key:
+ continue
+
try:
registry, namespace, name, current_version =
self.parse_container_reference(value)
@@ -792,6 +1182,10 @@ class ContainerVersionChecker:
if freeze_major_key in properties:
version_freeze_major =
properties[freeze_major_key].lower() in ['true', 'yes', '1']
+ # Detect whether this property has arch-specific override
siblings
+ arch_overrides = keys_arch_override_platforms.get(key, [])
+ has_arch_override = bool(arch_overrides)
+
image = ContainerImage(
registry=registry,
namespace=namespace,
@@ -801,7 +1195,10 @@ class ContainerVersionChecker:
file_path=file_path,
version_include=version_include,
version_exclude=version_exclude,
- version_freeze_major=version_freeze_major
+ version_freeze_major=version_freeze_major,
+ has_arch_override=has_arch_override,
+ arch_overrides=arch_overrides,
+ skipped_arches=module_skipped_arches,
)
images.append(image)
processed_keys.add(key)
@@ -814,6 +1211,10 @@ class ContainerVersionChecker:
print(f" Exclude filter: {',
'.join(version_exclude)}")
if version_freeze_major:
print(f" Major version freeze: enabled")
+ if arch_overrides:
+ print(f" Arch-specific overrides present for:
{', '.join(arch_overrides)}")
+ if module_skipped_arches:
+ print(f" skipITs arches (pom.xml): {',
'.join(module_skipped_arches)}")
except ValueError as e:
if self.verbose:
@@ -906,12 +1307,58 @@ class ContainerVersionChecker:
latest_version = sorted_versions[0] if sorted_versions else None
is_latest = current_ver == latest_version or len(newer_versions)
== 0
+ # Multi-arch check: verify the version the workflow will actually
bump to
+ # (latest_version = sorted_versions[0]) exposes the required
architectures
+ # in its manifest list.
+ #
+ # Architectures that are already covered by an explicit
arch-override
+ # property (image.arch_overrides) or skipped for this module's
tests
+ # (image.skipped_arches) are removed from the required set before
checking,
+ # so the check is precise rather than all-or-nothing.
+ arch_missing: List[str] = []
+ arch_unverified = False
+ if (
+ not self.skip_arch_check
+ and newer_versions
+ and latest_version is not None
+ and api is not None
+ ):
+ # Build the set of arches we actually need to check for this
image.
+ exempted = set(image.arch_overrides or []) |
set(image.skipped_arches or [])
+ arches_to_check = [a for a in REQUIRED_ARCHITECTURES if a not
in exempted]
+
+ if arches_to_check:
+ # The workflow bumps to latest_version, so that is what we
verify.
+ candidate = latest_version
+ if self.verbose:
+ print(f" Checking multi-arch manifest for
{image.full_name}:{candidate}...")
+ raw_missing = api.check_architectures(image, candidate)
+ if raw_missing is None:
+ # The check could not be performed (network error,
auth failure, etc.)
+ arch_unverified = True
+ if self.verbose:
+ print(f" ⚠ Multi-arch check could not be
performed for {candidate}")
+ else:
+ # Only report arches we still need to check (not
exempted ones).
+ arch_missing = [a for a in raw_missing if a in
arches_to_check]
+ if arch_missing:
+ if self.verbose:
+ print(f" ⚠ Missing architectures in
{candidate}: {', '.join(arch_missing)}")
+ # Remove versions that lack the required
architectures from the
+ # candidate list so the bot does not propose them
as upgrades.
+ # We only check the top candidate; if it fails we
conservatively
+ # treat the whole newer_versions list as
unavailable.
+ newer_versions = []
+ is_latest = True # Suppress the "outdated"
classification
+
return VersionCheckResult(
image=image,
available_versions=sorted_versions[:10], # Limit to top 10
latest_version=latest_version,
newer_versions=newer_versions[:5], # Limit to top 5 newer
- is_latest=is_latest
+ is_latest=is_latest,
+ arch_missing=arch_missing,
+ arch_unverified=arch_unverified,
)
except Exception as e:
@@ -924,6 +1371,24 @@ class ContainerVersionChecker:
error=str(e)
)
+ @staticmethod
+ def _find_repo_root(scan_path: str) -> str:
+ """Walk up from *scan_path* to find the repository root.
+
+ The root is the first ancestor directory that contains a
``components/``
+ sub-directory (which is where the component poms live) **or** a
``.git``
+ marker. Falls back to *scan_path* if neither is found.
+ """
+ current = Path(scan_path).resolve()
+ for _ in range(20): # safety bound
+ if (current / 'components').is_dir() or (current /
'.git').exists():
+ return str(current)
+ parent = current.parent
+ if parent == current:
+ break
+ current = parent
+ return scan_path
+
def run_check(self, scan_path: str, quiet: bool = False) ->
List[VersionCheckResult]:
"""Run the version check on all container.properties files."""
if not quiet:
@@ -939,6 +1404,17 @@ class ContainerVersionChecker:
if not quiet:
print(f"📁 Found {len(properties_files)} container.properties
files")
+ # Pre-build the component skipITs map so parse_properties_file can use
it.
+ # This handles the common case where skipITs.ppc64le/s390x lives in a
component
+ # pom rather than in the test-infra module pom.
+ # We scan from the repository root (not just scan_path) because the
component
+ # poms that set skipITs.* are typically under components/, which may
be outside
+ # the scan_path (e.g. when scan_path is 'test-infra').
+ repo_root = self._find_repo_root(scan_path)
+ self._component_skip_its_map =
self._build_component_skip_its_map(repo_root)
+ if self.verbose and self._component_skip_its_map:
+ print(f" Built skipITs map for
{len(self._component_skip_its_map)} test-infra modules from component poms
(scanned {repo_root})")
+
all_images = []
# Parse all properties files
@@ -990,7 +1466,9 @@ def print_results(results: List[VersionCheckResult],
verbose: bool = False):
# Separate results into categories
outdated = [r for r in results if not r.is_latest and not r.error and
r.newer_versions]
- up_to_date = [r for r in results if r.is_latest and not r.error]
+ arch_blocked = [r for r in results if r.arch_missing and not r.error]
+ arch_unverified = [r for r in results if r.arch_unverified and not r.error]
+ up_to_date = [r for r in results if r.is_latest and not r.error and not
r.arch_missing]
errors = [r for r in results if r.error]
print(f"\n{Style.BRIGHT}📊 VERSION CHECK SUMMARY{Style.RESET_ALL}")
@@ -999,6 +1477,10 @@ def print_results(results: List[VersionCheckResult],
verbose: bool = False):
print(f"Total images checked: {len(results)}")
print(f"{Fore.GREEN}✅ Up to date: {len(up_to_date)}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}⚠️ Outdated: {len(outdated)}{Style.RESET_ALL}")
+ if arch_blocked:
+ print(f"{Fore.MAGENTA}🏗️ Arch-incomplete (upgrade blocked):
{len(arch_blocked)}{Style.RESET_ALL}")
+ if arch_unverified:
+ print(f"{Fore.YELLOW}⚠️ Multi-arch not verified:
{len(arch_unverified)}{Style.RESET_ALL}")
print(f"{Fore.RED}❌ Errors: {len(errors)}{Style.RESET_ALL}")
if outdated:
@@ -1018,6 +1500,38 @@ def print_results(results: List[VersionCheckResult],
verbose: bool = False):
newer_display.append(f"... (+{len(result.newer_versions) -
3} more)")
print(f" Newer: {Fore.GREEN}{',
'.join(newer_display)}{Style.RESET_ALL}")
+ if arch_blocked:
+ print(f"\n{Style.BRIGHT}{Fore.MAGENTA}🏗️ ARCH-INCOMPLETE IMAGES
(upgrade blocked){Style.RESET_ALL}")
+ print("-" * 50)
+ print(f" These images have a newer version available but it is
missing")
+ print(f" required architectures in its manifest list.")
+ print(f" Upgrade skipped — ensure the image is published for all")
+ print(f" required platforms ({', '.join(REQUIRED_ARCHITECTURES)})
before upgrading,")
+ print(f" or add an arch-specific property override in
container.properties.")
+
+ for result in arch_blocked:
+ image = result.image
+ print(f"\n{Fore.CYAN}{image.property_name}{Style.RESET_ALL}")
+ print(f" File: {os.path.relpath(image.file_path)}")
+ print(f" Current:
{Fore.YELLOW}{image.current_version}{Style.RESET_ALL}")
+ print(f" Latest:
{Fore.MAGENTA}{result.latest_version}{Style.RESET_ALL}")
+ print(f" Missing: {Fore.MAGENTA}{', '.join(result.arch_missing or
[])}{Style.RESET_ALL}")
+
+ if arch_unverified:
+ print(f"\n{Style.BRIGHT}{Fore.YELLOW}⚠️ MULTI-ARCH NOT
VERIFIED{Style.RESET_ALL}")
+ print("-" * 50)
+ print(f" The multi-arch manifest check could not be performed for
these images")
+ print(f" (network error, authentication failure, or unsupported
registry format).")
+ print(f" Upgrades were still proposed but multi-arch availability is
unconfirmed.")
+
+ for result in arch_unverified:
+ image = result.image
+ print(f"\n{Fore.CYAN}{image.property_name}{Style.RESET_ALL}")
+ print(f" File: {os.path.relpath(image.file_path)}")
+ print(f" Current:
{Fore.YELLOW}{image.current_version}{Style.RESET_ALL}")
+ if result.latest_version:
+ print(f" Latest:
{Fore.YELLOW}{result.latest_version}{Style.RESET_ALL}")
+
if errors:
print(f"\n{Style.BRIGHT}{Fore.RED}❌ ERRORS{Style.RESET_ALL}")
print("-" * 15)
@@ -1047,7 +1561,9 @@ def output_json(results: List[VersionCheckResult]) -> str:
'latest_version': result.latest_version,
'newer_versions': result.newer_versions,
'is_latest': result.is_latest,
- 'error': result.error
+ 'error': result.error,
+ 'arch_missing': result.arch_missing or [],
+ 'arch_unverified': result.arch_unverified,
}
json_results.append(json_result)
@@ -1098,6 +1614,12 @@ Examples:
help='Registry API timeout in seconds (default: 30)'
)
+ parser.add_argument(
+ '--skip-arch-check',
+ action='store_true',
+ help='Skip multi-arch manifest verification (default: check
linux/s390x and linux/ppc64le)'
+ )
+
parser.add_argument(
'--test-infra-path',
type=str,
@@ -1141,7 +1663,8 @@ Examples:
checker = ContainerVersionChecker(
include_prereleases=args.check_prereleases,
registry_timeout=args.registry_timeout,
- verbose=args.verbose
+ verbose=args.verbose,
+ skip_arch_check=args.skip_arch_check
)
try:
diff --git
a/.github/actions/check-container-upgrade/test_check_container_versions.py
b/.github/actions/check-container-upgrade/test_check_container_versions.py
new file mode 100644
index 000000000000..10fc546ce0f6
--- /dev/null
+++ b/.github/actions/check-container-upgrade/test_check_container_versions.py
@@ -0,0 +1,779 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Tests for the multi-arch manifest verification added by CAMEL-26492.
+
+Run with: python3 -m unittest test_check_container_versions
+"""
+
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+HERE = Path(__file__).parent
+
+_spec = importlib.util.spec_from_file_location(
+ "check_container_versions", HERE / "check-container-versions.py"
+)
+checker_mod = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(checker_mod)
+
+ContainerImage = checker_mod.ContainerImage
+ContainerVersionChecker = checker_mod.ContainerVersionChecker
+ContainerRegistryAPI = checker_mod.ContainerRegistryAPI
+DockerHubAPI = checker_mod.DockerHubAPI
+QuayAPI = checker_mod.QuayAPI
+REQUIRED_ARCHITECTURES = checker_mod.REQUIRED_ARCHITECTURES
+VersionCheckResult = checker_mod.VersionCheckResult
+
+
+def make_image(registry="mirror.gcr.io", namespace="library", name="postgres",
+ current_version="17.0", property_name="postgres.container",
+ has_arch_override=False, arch_overrides=None,
skipped_arches=None):
+ return ContainerImage(
+ registry=registry,
+ namespace=namespace,
+ name=name,
+ current_version=current_version,
+ property_name=property_name,
+ file_path="/fake/container.properties",
+ has_arch_override=has_arch_override,
+ arch_overrides=arch_overrides if arch_overrides is not None else [],
+ skipped_arches=skipped_arches if skipped_arches is not None else [],
+ )
+
+
+def manifest_response(architectures):
+ """Build a fake manifest list JSON payload for the given os/arch
strings."""
+ manifests = [
+ {"platform": {"os": os_, "architecture": arch_}}
+ for os_, arch_ in (a.split("/") for a in architectures)
+ ]
+ return {"manifests": manifests, "mediaType":
"application/vnd.docker.distribution.manifest.list.v2+json"}
+
+
+class RequiredArchitecturesTest(unittest.TestCase):
+
+ def test_required_architectures_includes_s390x_and_ppc64le(self):
+ self.assertIn("linux/s390x", REQUIRED_ARCHITECTURES)
+ self.assertIn("linux/ppc64le", REQUIRED_ARCHITECTURES)
+
+
+class ContainerRegistryAPICheckArchitecturesTest(unittest.TestCase):
+ """Unit tests for ContainerRegistryAPI.check_architectures()."""
+
+ def _api(self):
+ api = checker_mod.GCRAPI(timeout=5)
+ return api
+
+ def _mock_response(self, payload, status=200):
+ resp = MagicMock()
+ resp.status_code = status
+ resp.json.return_value = payload
+ return resp
+
+ def test_returns_empty_when_all_arches_present(self):
+ api = self._api()
+ image = make_image()
+ with patch.object(api.session, "get",
+ return_value=self._mock_response(
+ manifest_response(["linux/amd64", "linux/s390x",
"linux/ppc64le", "linux/arm64"]))):
+ missing = api.check_architectures(image, "17.1")
+ self.assertEqual([], missing)
+
+ def test_returns_missing_ppc64le(self):
+ api = self._api()
+ image = make_image()
+ with patch.object(api.session, "get",
+ return_value=self._mock_response(
+ manifest_response(["linux/amd64", "linux/s390x",
"linux/arm64"]))):
+ missing = api.check_architectures(image, "17.1")
+ self.assertEqual(["linux/ppc64le"], missing)
+
+ def test_returns_both_missing(self):
+ api = self._api()
+ image = make_image()
+ with patch.object(api.session, "get",
+ return_value=self._mock_response(
+ manifest_response(["linux/amd64",
"linux/arm64"]))):
+ missing = api.check_architectures(image, "17.1")
+ self.assertIn("linux/s390x", missing)
+ self.assertIn("linux/ppc64le", missing)
+
+ def test_returns_none_on_non_200(self):
+ api = self._api()
+ image = make_image()
+ with patch.object(api.session, "get",
+ return_value=self._mock_response({}, status=404)):
+ missing = api.check_architectures(image, "17.1")
+ self.assertIsNone(missing)
+
+ def test_returns_missing_arches_on_single_arch_manifest(self):
+ """A single-arch manifest (no 'manifests' key) must count as
arch-missing, not pass."""
+ api = self._api()
+ image = make_image()
+ # Single-arch manifest has no 'manifests' key
+ with patch.object(api.session, "get",
+ return_value=self._mock_response({"schemaVersion":
2, "layers": []})):
+ missing = api.check_architectures(image, "17.1")
+ # Both required arches must be reported as missing
+ self.assertIn("linux/s390x", missing)
+ self.assertIn("linux/ppc64le", missing)
+
+ def test_returns_none_on_exception(self):
+ api = self._api()
+ image = make_image()
+ with patch.object(api.session, "get", side_effect=Exception("network
error")):
+ missing = api.check_architectures(image, "17.1")
+ self.assertIsNone(missing)
+
+
+class DockerHubAPICheckArchitecturesTest(unittest.TestCase):
+ """DockerHub override: obtains a Bearer token before fetching the
manifest."""
+
+ def _mock_response(self, payload, status=200):
+ resp = MagicMock()
+ resp.status_code = status
+ resp.json.return_value = payload
+ return resp
+
+ def test_uses_token_for_manifest_fetch(self):
+ api = DockerHubAPI(timeout=5)
+ image = make_image(registry="docker.io", namespace="library",
name="postgres")
+ token_resp = self._mock_response({"token": "mytoken"})
+ manifest_resp = self._mock_response(
+ manifest_response(["linux/amd64", "linux/s390x", "linux/ppc64le"])
+ )
+ with patch.object(api.session, "get", side_effect=[token_resp,
manifest_resp]) as mock_get:
+ missing = api.check_architectures(image, "17.1")
+ self.assertEqual([], missing)
+ # Second call must carry the Authorization header
+ _, kwargs = mock_get.call_args_list[1]
+ headers = kwargs.get("headers", {})
+ self.assertIn("Authorization", headers)
+ self.assertIn("mytoken", headers["Authorization"])
+
+ def test_official_image_uses_library_prefix(self):
+ api = DockerHubAPI(timeout=5)
+ image = make_image(registry="docker.io", namespace="", name="postgres")
+ with patch.object(api.session, "get",
+ side_effect=[self._mock_response({"token": "t"}),
+
self._mock_response(manifest_response(["linux/amd64"]))]) as mock_get:
+ api.check_architectures(image, "17.1")
+ manifest_url = mock_get.call_args_list[1][0][0]
+ self.assertIn("library/postgres", manifest_url)
+
+
+class QuayAPICheckArchitecturesTest(unittest.TestCase):
+ """QuayAPI override uses quay.io/v2/<repo>/manifests/<tag>."""
+
+ def _mock_response(self, payload, status=200):
+ resp = MagicMock()
+ resp.status_code = status
+ resp.json.return_value = payload
+ return resp
+
+ def test_uses_v2_manifest_endpoint(self):
+ api = QuayAPI(timeout=5)
+ image = make_image(registry="quay.io", namespace="strimzi",
name="kafka")
+ with patch.object(api.session, "get",
+ return_value=self._mock_response(
+ manifest_response(["linux/amd64", "linux/s390x",
"linux/ppc64le"])
+ )) as mock_get:
+ missing = api.check_architectures(image, "latest-kafka-3.9.1")
+ self.assertEqual([], missing)
+ url = mock_get.call_args[0][0]
+ self.assertIn("quay.io/v2/strimzi/kafka/manifests/latest-kafka-3.9.1",
url)
+
+
+class ParsePropertiesArchOverrideTest(unittest.TestCase):
+ """parse_properties_file must set has_arch_override for images that have
arch siblings."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.checker = ContainerVersionChecker()
+
+ def _write(self, content):
+ path = os.path.join(self.tmp.name, "container.properties")
+ with open(path, "w") as f:
+ f.write(content)
+ return path
+
+ def test_has_arch_override_set_when_ppc64le_sibling_present(self):
+ path = self._write(
+ "keycloak.container=mirror.gcr.io/keycloak/keycloak:26.0.0\n"
+
"keycloak.container.ppc64le=icr.io/ppc64le-oss/keycloak-ppc64le:v26.0.0-bv\n"
+ )
+ images = self.checker.parse_properties_file(path)
+ main = next((i for i in images if i.property_name ==
"keycloak.container"), None)
+ self.assertIsNotNone(main)
+ self.assertTrue(main.has_arch_override,
+ "keycloak.container should be flagged because
keycloak.container.ppc64le exists")
+
+ def test_has_arch_override_set_when_s390x_sibling_present(self):
+ path = self._write(
+ "cassandra.container=mirror.gcr.io/cassandra:4.0.0\n"
+ "cassandra.container.s390x=mirror.gcr.io/s390x/cassandra:4.0.0\n"
+ )
+ images = self.checker.parse_properties_file(path)
+ main = next((i for i in images if i.property_name ==
"cassandra.container"), None)
+ self.assertIsNotNone(main)
+ self.assertTrue(main.has_arch_override)
+
+ def test_no_arch_override_when_no_arch_sibling(self):
+ path = self._write(
+ "kafka.container=quay.io/strimzi/kafka:latest-kafka-3.9.1\n"
+ )
+ images = self.checker.parse_properties_file(path)
+ self.assertEqual(1, len(images))
+ self.assertFalse(images[0].has_arch_override)
+
+ def test_arch_override_property_itself_not_in_returned_images(self):
+ """Arch-specific override properties (e.g. keycloak.container.ppc64le)
contain
+ platform-specific registry refs that cannot be parsed as a standard
container
+ ref. Whether they parse or not, the caller only cares about the base
image."""
+ path = self._write(
+ "kafka.container=quay.io/strimzi/kafka:3.9.1\n"
+ "kafka.container.ppc64le=icr.io/ppc64le-oss/kafka-ppc64le:3.9.1\n"
+ )
+ images = self.checker.parse_properties_file(path)
+ names = {i.property_name for i in images}
+ # The base property must be present
+ self.assertIn("kafka.container", names)
+
+
+class CheckImageVersionsArchGatingTest(unittest.TestCase):
+ """check_image_versions must gate upgrades on multi-arch availability."""
+
+ def setUp(self):
+ self.checker = ContainerVersionChecker(skip_arch_check=False)
+ # Replace the GCR API with a mock
+ self.mock_api = MagicMock()
+ self.checker.registry_apis["gcr.io"] = self.mock_api
+ self.checker.registry_apis["mirror.gcr.io"] = self.mock_api
+
+ def test_upgrade_proposed_when_all_arches_present(self):
+ image = make_image(current_version="17.0", has_arch_override=False)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = [] # all good
+
+ result = self.checker.check_image_versions(image)
+
+ self.assertEqual(["17.1"], result.newer_versions)
+ self.assertFalse(result.is_latest)
+ self.assertEqual([], result.arch_missing)
+
+ def test_upgrade_blocked_when_arch_missing(self):
+ image = make_image(current_version="17.0", has_arch_override=False)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = ["linux/ppc64le"]
+
+ result = self.checker.check_image_versions(image)
+
+ self.assertEqual([], result.newer_versions,
+ "No upgrade should be proposed when ppc64le is
missing")
+ self.assertTrue(result.is_latest)
+ self.assertIn("linux/ppc64le", result.arch_missing)
+
+ def test_arch_check_targets_latest_version_not_just_newer(self):
+ """The arch check must verify latest_version (what the workflow bumps
to)."""
+ image = make_image(current_version="17.0", has_arch_override=False)
+ # latest_version = "18.0" (sorted_versions[0]); newer_versions[0]
would be "17.1"
+ self.mock_api.get_available_versions.return_value = ["18.0", "17.1",
"17.0"]
+ self.mock_api.check_architectures.return_value = ["linux/ppc64le"]
+
+ result = self.checker.check_image_versions(image)
+
+ # check_architectures should have been called with "18.0"
(latest_version), not "17.1"
+ called_with_tag = self.mock_api.check_architectures.call_args[0][1]
+ self.assertEqual("18.0", called_with_tag,
+ "Arch check must target latest_version, not
newer_versions[0]")
+
+ def test_partial_arch_override_still_checks_unoverridden_arch(self):
+ """Only ppc64le is overridden → s390x must still be checked and can
block an upgrade."""
+ image = make_image(current_version="17.0", has_arch_override=True,
+ arch_overrides=["linux/ppc64le"])
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ # s390x is missing; ppc64le is covered by arch_override
+ self.mock_api.check_architectures.return_value = ["linux/s390x",
"linux/ppc64le"]
+
+ result = self.checker.check_image_versions(image)
+
+ # Only linux/s390x should block (linux/ppc64le is exempted)
+ self.assertEqual([], result.newer_versions)
+ self.assertIn("linux/s390x", result.arch_missing)
+ self.assertNotIn("linux/ppc64le", result.arch_missing)
+
+ def test_skipped_arches_exempted_from_check(self):
+ """Arches marked skipITs.* in pom.xml must be exempt from the manifest
check."""
+ image = make_image(current_version="17.0", has_arch_override=False,
+ skipped_arches=["linux/s390x", "linux/ppc64le"])
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = ["linux/s390x",
"linux/ppc64le"]
+
+ result = self.checker.check_image_versions(image)
+
+ # Both arches are skipped → upgrade should be proposed
+ self.mock_api.check_architectures.assert_not_called()
+ self.assertEqual(["17.1"], result.newer_versions)
+
+ def test_upgrade_proposed_when_has_arch_override(self):
+ """Images with explicit arch overrides for all required arches skip
the multi-arch check."""
+ image = make_image(current_version="17.0", has_arch_override=True,
+ arch_overrides=REQUIRED_ARCHITECTURES)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ # check_architectures should NOT be called when all required arches
are overridden
+ self.mock_api.check_architectures.return_value = ["linux/s390x",
"linux/ppc64le"]
+
+ result = self.checker.check_image_versions(image)
+
+ self.mock_api.check_architectures.assert_not_called()
+ self.assertEqual(["17.1"], result.newer_versions)
+ self.assertEqual([], result.arch_missing)
+
+ def test_upgrade_proposed_when_skip_arch_check(self):
+ """--skip-arch-check bypasses the manifest verification."""
+ self.checker.skip_arch_check = True
+ image = make_image(current_version="17.0", has_arch_override=False)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = ["linux/s390x"]
+
+ result = self.checker.check_image_versions(image)
+
+ self.mock_api.check_architectures.assert_not_called()
+ self.assertEqual(["17.1"], result.newer_versions)
+
+ def test_arch_missing_included_in_json_output(self):
+ result = VersionCheckResult(
+ image=make_image(),
+ available_versions=["17.1", "17.0"],
+ latest_version="17.1",
+ newer_versions=[],
+ is_latest=True,
+ arch_missing=["linux/s390x"],
+ )
+ json_str = checker_mod.output_json([result])
+ data = json.loads(json_str)
+ self.assertEqual(["linux/s390x"], data[0]["arch_missing"])
+
+
+class GCRAPICheckArchitecturesTest(unittest.TestCase):
+ """GCRAPI.check_architectures must use the original registry
(mirror.gcr.io) not normalise it."""
+
+ def _mock_response(self, payload, status=200):
+ resp = MagicMock()
+ resp.status_code = status
+ resp.json.return_value = payload
+ return resp
+
+ def test_uses_mirror_gcr_io_directly_for_manifest(self):
+ """mirror.gcr.io must be used as-is; normalising to gcr.io causes
401."""
+ GCRAPI = checker_mod.GCRAPI
+ api = GCRAPI(timeout=5)
+ image = make_image(registry="mirror.gcr.io", namespace="apache",
name="kafka")
+ with patch.object(api.session, "get",
+ return_value=self._mock_response(
+ manifest_response(["linux/amd64", "linux/s390x",
"linux/ppc64le"])
+ )) as mock_get:
+ missing = api.check_architectures(image, "3.9.1")
+ self.assertEqual([], missing)
+ url = mock_get.call_args[0][0]
+ self.assertIn("mirror.gcr.io", url, "Manifest URL must use
mirror.gcr.io, not gcr.io")
+ self.assertNotIn("//gcr.io", url)
+
+ def test_single_arch_counts_as_missing(self):
+ """A single-arch manifest (no 'manifests' key) must block the
upgrade."""
+ GCRAPI = checker_mod.GCRAPI
+ api = GCRAPI(timeout=5)
+ image = make_image(registry="mirror.gcr.io", namespace="library",
name="postgres")
+ with patch.object(api.session, "get",
+ return_value=self._mock_response({"schemaVersion":
2, "layers": []})):
+ missing = api.check_architectures(image, "17.1")
+ self.assertIn("linux/s390x", missing)
+ self.assertIn("linux/ppc64le", missing)
+
+
+class ComponentSkipItsMapTest(unittest.TestCase):
+ """_build_component_skip_its_map must find skipITs in component poms that
depend on test-infra."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.base = Path(self.tmp.name)
+
+ def _write_pom(self, path: Path, content: str):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+
+ def test_discovers_skipped_arches_from_component_pom(self):
+ # Component pom references camel-test-infra-elasticsearch and sets
skipITs.s390x
+ self._write_pom(
+ self.base / "components" / "camel-elasticsearch" / "pom.xml",
+ """<project><properties>
+ <skipITs.s390x>true</skipITs.s390x>
+ </properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-elasticsearch</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ self.assertIn("camel-test-infra-elasticsearch", result)
+ self.assertIn("linux/s390x", result["camel-test-infra-elasticsearch"])
+
+ def test_no_entry_when_no_skip_its(self):
+ self._write_pom(
+ self.base / "components" / "camel-kafka" / "pom.xml",
+ """<project><dependencies><dependency>
+ <artifactId>camel-test-infra-kafka</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ self.assertNotIn("camel-test-infra-kafka", result)
+
+ def test_intersection_across_consumers_not_union(self):
+ """When multiple consumers reference the same test-infra module, an
arch is only
+ exempt when ALL consumers skip it (intersection), not when any one
does (union)."""
+ # Consumer A skips s390x only; consumer B skips ppc64le only.
+ # Intersection is empty → nothing should be exempted.
+ self._write_pom(
+ self.base / "components" / "camel-es-a" / "pom.xml",
+
"""<project><properties><skipITs.s390x>true</skipITs.s390x></properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-es</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ self._write_pom(
+ self.base / "components" / "camel-es-b" / "pom.xml",
+
"""<project><properties><skipITs.ppc64le>true</skipITs.ppc64le></properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-es</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ # Intersection of {s390x} and {ppc64le} is empty
+ self.assertEqual(result.get("camel-test-infra-es", []), [])
+
+ def test_intersection_all_consumers_skip_same_arch(self):
+ """When ALL consumers skip the same arch, it appears in the result."""
+ self._write_pom(
+ self.base / "components" / "camel-es-a" / "pom.xml",
+ """<project><properties>
+ <skipITs.s390x>true</skipITs.s390x>
+ <skipITs.ppc64le>true</skipITs.ppc64le>
+ </properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-es</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ self._write_pom(
+ self.base / "components" / "camel-es-b" / "pom.xml",
+
"""<project><properties><skipITs.s390x>true</skipITs.s390x></properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-es</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ # Intersection: both skip s390x, only one skips ppc64le
+ self.assertIn("linux/s390x", result.get("camel-test-infra-es", []))
+ self.assertNotIn("linux/ppc64le", result.get("camel-test-infra-es",
[]))
+
+ def test_test_infra_all_pom_is_ignored(self):
+ """test-infra/camel-test-infra-all/pom.xml references every module but
must be skipped."""
+ # Component pom that legitimately skips s390x
+ self._write_pom(
+ self.base / "components" / "camel-elasticsearch" / "pom.xml",
+ """<project><properties>
+ <skipITs.s390x>true</skipITs.s390x>
+ </properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-elasticsearch</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ # test-infra-all aggregator pom — references every module, no skipITs
+ self._write_pom(
+ self.base / "test-infra" / "camel-test-infra-all" / "pom.xml",
+ """<project><modules>
+ <module>../camel-test-infra-elasticsearch</module>
+ </modules>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-elasticsearch</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ # The -all pom should be ignored; only the component pom counts
+ self.assertIn("camel-test-infra-elasticsearch", result)
+ self.assertIn("linux/s390x", result["camel-test-infra-elasticsearch"])
+
+ def test_consumer_without_skip_its_clears_exemption(self):
+ """A consumer pom with no skipITs.* should clear all exemptions via
intersection."""
+ # Consumer A skips both arches; consumer B has no skipITs at all
+ self._write_pom(
+ self.base / "components" / "camel-pg-a" / "pom.xml",
+ """<project><properties>
+ <skipITs.s390x>true</skipITs.s390x>
+ <skipITs.ppc64le>true</skipITs.ppc64le>
+ </properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-postgres</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ self._write_pom(
+ self.base / "components" / "camel-pg-b" / "pom.xml",
+ """<project>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-postgres</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ result =
ContainerVersionChecker._build_component_skip_its_map(str(self.base))
+ # Intersection of {s390x, ppc64le} and {} is empty
+ self.assertEqual(result.get("camel-test-infra-postgres", []), [])
+
+
+
+class ReadSkipItsPomTest(unittest.TestCase):
+ """Unit tests for ContainerVersionChecker._read_skip_its_from_pom."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+
+ def _write_pom(self, path, content):
+ pom = Path(path) / 'pom.xml'
+ pom.parent.mkdir(parents=True, exist_ok=True)
+ pom.write_text(content)
+ return str(pom.parent)
+
+ def test_ppc64le_and_s390x_set(self):
+ pom_content = """<project><properties>
+ <skipITs.ppc64le>true</skipITs.ppc64le>
+ <skipITs.s390x>true</skipITs.s390x>
+ </properties></project>"""
+ module_dir = self._write_pom(Path(self.tmp.name) / 'module',
pom_content)
+ # container.properties lives inside the module directory
+ props_path = str(Path(module_dir) / 'src' / 'container.properties')
+ result = ContainerVersionChecker._read_skip_its_from_pom(props_path)
+ self.assertIn('linux/ppc64le', result)
+ self.assertIn('linux/s390x', result)
+
+ def test_no_skip_its(self):
+ pom_content = "<project><properties></properties></project>"
+ module_dir = self._write_pom(Path(self.tmp.name) / 'module2',
pom_content)
+ props_path = str(Path(module_dir) / 'container.properties')
+ result = ContainerVersionChecker._read_skip_its_from_pom(props_path)
+ self.assertEqual([], result)
+
+ def test_only_ppc64le_skipped(self):
+ pom_content = """<project><properties>
+ <skipITs.ppc64le>true</skipITs.ppc64le>
+ </properties></project>"""
+ module_dir = self._write_pom(Path(self.tmp.name) / 'module3',
pom_content)
+ props_path = str(Path(module_dir) / 'container.properties')
+ result = ContainerVersionChecker._read_skip_its_from_pom(props_path)
+ self.assertIn('linux/ppc64le', result)
+ self.assertNotIn('linux/s390x', result)
+
+
+class BearerTokenChallengeTest(unittest.TestCase):
+ """Unit tests for ContainerRegistryAPI._bearer_token_from_challenge."""
+
+ def setUp(self):
+ self.api = checker_mod.GCRAPI(timeout=5)
+
+ def test_extracts_token_from_challenge(self):
+ challenge = 'Bearer
realm="https://auth.example.com/token",service="example",scope="pull"'
+ mock_resp = MagicMock()
+ mock_resp.status_code = 200
+ mock_resp.json.return_value = {'token': 'abc123'}
+ with patch.object(self.api.session, 'get', return_value=mock_resp):
+ token = self.api._bearer_token_from_challenge(challenge)
+ self.assertEqual('abc123', token)
+
+ def test_returns_empty_for_non_bearer(self):
+ token = self.api._bearer_token_from_challenge('Basic realm="example"')
+ self.assertEqual('', token)
+
+ def test_returns_empty_when_no_realm(self):
+ token = self.api._bearer_token_from_challenge('Bearer
service="example"')
+ self.assertEqual('', token)
+
+
+class FindRepoRootTest(unittest.TestCase):
+ """_find_repo_root must walk up to the directory containing components/ or
.git."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name) / "repo"
+ self.root.mkdir()
+
+ def test_finds_root_by_components_dir(self):
+ (self.root / "components").mkdir()
+ test_infra = self.root / "test-infra"
+ test_infra.mkdir()
+ result = ContainerVersionChecker._find_repo_root(str(test_infra))
+ self.assertEqual(str(self.root), result)
+
+ def test_finds_root_by_git_marker(self):
+ (self.root / ".git").mkdir()
+ test_infra = self.root / "test-infra" / "sub"
+ test_infra.mkdir(parents=True)
+ result = ContainerVersionChecker._find_repo_root(str(test_infra))
+ self.assertEqual(str(self.root), result)
+
+ def test_falls_back_to_scan_path(self):
+ # No components/ or .git above — falls back to scan_path itself
+ deep = self.root / "a" / "b" / "c"
+ deep.mkdir(parents=True)
+ result = ContainerVersionChecker._find_repo_root(str(deep))
+ self.assertEqual(str(deep), result)
+
+
+class RunCheckRepoRootTest(unittest.TestCase):
+ """run_check with scan_path=test-infra must still find skipITs in
components/ via _find_repo_root."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+
+ def _write(self, relpath, content):
+ p = self.root / relpath
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content)
+
+ def test_skip_its_found_from_components_when_scanning_test_infra(self):
+ """When scan_path is test-infra, skipITs in components/ must be
discovered."""
+ # Create the repo structure with components/ at root
+ (self.root / "components").mkdir()
+ # Component pom that skips s390x and references
camel-test-infra-elasticsearch
+ self._write(
+ "components/camel-elasticsearch/pom.xml",
+ """<project><properties>
+ <skipITs.s390x>true</skipITs.s390x>
+ <skipITs.ppc64le>true</skipITs.ppc64le>
+ </properties>
+ <dependencies><dependency>
+ <artifactId>camel-test-infra-elasticsearch</artifactId>
+ </dependency></dependencies></project>"""
+ )
+ # Create test-infra module with container.properties
+ self._write(
+
"test-infra/camel-test-infra-elasticsearch/src/main/resources/container.properties",
+
"elasticsearch.container=docker.elastic.co/elasticsearch/elasticsearch:8.17.3\n"
+ )
+ # Create a pom.xml in the test-infra module
+ self._write(
+ "test-infra/camel-test-infra-elasticsearch/pom.xml",
+
"<project><artifactId>camel-test-infra-elasticsearch</artifactId></project>"
+ )
+
+ checker = ContainerVersionChecker(skip_arch_check=True)
+ # Patch check_image_versions to keep the test offline
+ fake_result = VersionCheckResult(
+ image=make_image(
+ registry="docker.elastic.co",
+ namespace="elasticsearch",
+ name="elasticsearch",
+ current_version="8.17.3",
+ property_name="elasticsearch.container",
+ skipped_arches=["linux/s390x", "linux/ppc64le"],
+ ),
+ available_versions=["8.17.3"],
+ latest_version="8.17.3",
+ newer_versions=[],
+ is_latest=True,
+ )
+ with patch.object(checker, 'check_image_versions',
return_value=fake_result):
+ # Scan only test-infra — _find_repo_root should walk up to
self.root
+ scan_path = str(self.root / "test-infra")
+ results = checker.run_check(scan_path, quiet=True)
+
+ # The skipITs map should have been built from components/
+ self.assertIn("camel-test-infra-elasticsearch",
checker._component_skip_its_map)
+ skipped =
checker._component_skip_its_map["camel-test-infra-elasticsearch"]
+ self.assertIn("linux/s390x", skipped)
+ self.assertIn("linux/ppc64le", skipped)
+
+ # The mock result should be returned
+ self.assertEqual(len(results), 1)
+
+
+class ArchUnverifiedTest(unittest.TestCase):
+ """check_image_versions must set arch_unverified when check_architectures
returns None."""
+
+ def setUp(self):
+ self.checker = ContainerVersionChecker(skip_arch_check=False)
+ self.mock_api = MagicMock()
+ self.checker.registry_apis["gcr.io"] = self.mock_api
+ self.checker.registry_apis["mirror.gcr.io"] = self.mock_api
+
+ def test_arch_unverified_set_when_check_returns_none(self):
+ image = make_image(current_version="17.0", has_arch_override=False)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = None # could not
verify
+
+ result = self.checker.check_image_versions(image)
+
+ self.assertTrue(result.arch_unverified,
+ "arch_unverified should be True when
check_architectures returns None")
+ # Upgrade should still be proposed (not blocked)
+ self.assertEqual(["17.1"], result.newer_versions)
+ self.assertFalse(result.is_latest)
+
+ def test_arch_unverified_false_when_check_succeeds(self):
+ image = make_image(current_version="17.0", has_arch_override=False)
+ self.mock_api.get_available_versions.return_value = ["17.1", "17.0"]
+ self.mock_api.check_architectures.return_value = [] # all arches
present
+
+ result = self.checker.check_image_versions(image)
+
+ self.assertFalse(result.arch_unverified)
+
+ def test_arch_unverified_in_json_output(self):
+ result = VersionCheckResult(
+ image=make_image(),
+ available_versions=["17.1", "17.0"],
+ latest_version="17.1",
+ newer_versions=["17.1"],
+ is_latest=False,
+ arch_unverified=True,
+ )
+ json_str = checker_mod.output_json([result])
+ data = json.loads(json_str)
+ self.assertTrue(data[0]["arch_unverified"])
+
+ def test_arch_unverified_false_in_json_by_default(self):
+ result = VersionCheckResult(
+ image=make_image(),
+ available_versions=["17.1"],
+ latest_version="17.1",
+ newer_versions=[],
+ is_latest=True,
+ )
+ json_str = checker_mod.output_json([result])
+ data = json.loads(json_str)
+ self.assertFalse(data[0]["arch_unverified"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/check-container-versions.yml
b/.github/workflows/check-container-versions.yml
index 10aac581495c..476b4c2bdc32 100644
--- a/.github/workflows/check-container-versions.yml
+++ b/.github/workflows/check-container-versions.yml
@@ -401,3 +401,16 @@ jobs:
echo "⚠️ No Pull Requests were created" >> $GITHUB_STEP_SUMMARY
fi
fi
+
+ ARCH_BLOCKED=$(jq '[.[] | select(.arch_missing != null and
(.arch_missing | length) > 0)] | length' versions.json 2>/dev/null || echo "0")
+ if [[ "$ARCH_BLOCKED" -gt 0 ]]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "🏗️ **$ARCH_BLOCKED** image(s) have a newer version available
but it lacks linux/s390x or linux/ppc64le support — upgrade skipped." >>
$GITHUB_STEP_SUMMARY
+ echo "To investigate, run the checker locally with \`--verbose\`."
>> $GITHUB_STEP_SUMMARY
+ fi
+
+ ARCH_UNVERIFIED=$(jq '[.[] | select(.arch_unverified == true)] |
length' versions.json 2>/dev/null || echo "0")
+ if [[ "$ARCH_UNVERIFIED" -gt 0 ]]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "⚠️ **$ARCH_UNVERIFIED** image(s) could not be verified for
multi-arch availability (network error or unsupported registry). Upgrades were
proposed but linux/s390x and linux/ppc64le support is unconfirmed." >>
$GITHUB_STEP_SUMMARY
+ fi
diff --git a/.github/workflows/pr-ci-scripts-validation.yml
b/.github/workflows/pr-ci-scripts-validation.yml
index 982b1a00560e..1d1f9c8bdd95 100644
--- a/.github/workflows/pr-ci-scripts-validation.yml
+++ b/.github/workflows/pr-ci-scripts-validation.yml
@@ -41,6 +41,11 @@ jobs:
with:
persist-credentials: false
+ - name: Install container upgrade script dependencies
+ shell: bash
+ run: |
+ pip install --only-binary :all: --require-hashes -r
.github/actions/check-container-upgrade/requirements.txt
+
- name: Test the container upgrade scripts
shell: bash
run: |