This is an automated email from the ASF dual-hosted git repository.

acosentino pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 735e984551ee9676275ad7be84f4f88703f42b48
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Dec 2 09:39:32 2025 +0100

    Attempt at Check container upgrade
    
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../check-container-versions.py                    | 939 +++++++++++++++++++++
 .github/workflows/check-container-versions.yml     | 331 ++++++++
 2 files changed, 1270 insertions(+)

diff --git 
a/.github/actions/check-container-upgrade/check-container-versions.py 
b/.github/actions/check-container-upgrade/check-container-versions.py
new file mode 100755
index 000000000000..306c3e7a6e21
--- /dev/null
+++ b/.github/actions/check-container-upgrade/check-container-versions.py
@@ -0,0 +1,939 @@
+#!/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.
+#
+
+
+"""
+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.
+
+Supported Registries:
+    - Docker Hub (docker.io, registry-1.docker.io)
+    - Quay.io (quay.io)
+    - Google Container Registry (gcr.io, mirror.gcr.io)
+    - GitHub Container Registry (ghcr.io)
+    - IBM Container Registry (icr.io)
+    - Elastic Docker Registry (docker.elastic.co)
+    - NVIDIA Container Registry (nvcr.io)
+    - Microsoft Container Registry (mcr.microsoft.com)
+    - Weaviate Container Registry (cr.weaviate.io)
+
+Usage:
+    python3 check-container-versions.py [options]
+
+Options:
+    --verbose, -v        Enable verbose output
+    --json              Output results in JSON format
+    --check-prereleases  Include pre-release versions in checks
+    --registry-timeout   Registry API timeout in seconds (default: 30)
+    --help, -h          Show this help message
+
+Dependencies:
+    pip install requests packaging colorama
+"""
+
+import os
+import re
+import sys
+import json
+import argparse
+import configparser
+from pathlib import Path
+from typing import Dict, List, Tuple, Optional, Any
+from dataclasses import dataclass, asdict
+from urllib.parse import urlparse
+import requests
+from packaging import version
+from colorama import init, Fore, Style, Back
+import time
+
+# Initialize colorama for cross-platform colored output
+init(autoreset=True)
+
+@dataclass
+class ContainerImage:
+    """Represents a container image with its registry, name, and version."""
+    registry: str
+    namespace: str
+    name: str
+    current_version: str
+    property_name: str
+    file_path: str
+
+    @property
+    def full_name(self) -> str:
+        """Returns the full image name without version."""
+        if self.namespace:
+            return f"{self.registry}/{self.namespace}/{self.name}"
+        else:
+            return f"{self.registry}/{self.name}"
+
+    @property
+    def full_image(self) -> str:
+        """Returns the complete image reference with version."""
+        return f"{self.full_name}:{self.current_version}"
+
+@dataclass
+class VersionCheckResult:
+    """Result of checking for newer versions of a container image."""
+    image: ContainerImage
+    available_versions: List[str]
+    latest_version: Optional[str]
+    newer_versions: List[str]
+    is_latest: bool
+    error: Optional[str] = None
+
+class ContainerRegistryAPI:
+    """Base class for container registry API interactions."""
+
+    def __init__(self, timeout: int = 30):
+        self.timeout = timeout
+        self.session = requests.Session()
+        self.session.headers.update({
+            'User-Agent': 'Apache-Camel-Test-Infra-Version-Checker/1.0'
+        })
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions for the given image."""
+        raise NotImplementedError
+
+    def normalize_registry_url(self, registry: str) -> str:
+        """Normalize registry URL for API calls."""
+        if registry == "mirror.gcr.io":
+            return "gcr.io"
+        elif registry == "docker.io" or registry == "":
+            return "registry-1.docker.io"
+        return registry
+
+class DockerHubAPI(ContainerRegistryAPI):
+    """Docker Hub registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from Docker Hub."""
+        try:
+            # Handle official images (no namespace)
+            if not image.namespace or image.namespace == "library":
+                repo_name = image.name
+            else:
+                repo_name = f"{image.namespace}/{image.name}"
+
+            url = 
f"https://registry.hub.docker.com/v2/repositories/{repo_name}/tags/";
+
+            versions = []
+            page = 1
+            max_pages = 20  # Prevent infinite loops
+
+            while page <= max_pages:
+                params = {'page': page, 'page_size': 100}
+                response = self.session.get(url, params=params, 
timeout=self.timeout)
+
+                if response.status_code != 200:
+                    if response.status_code == 404:
+                        return []  # Repository not found
+                    response.raise_for_status()
+
+                data = response.json()
+
+                for tag_info in data.get('results', []):
+                    tag_name = tag_info.get('name', '')
+                    if tag_name and tag_name != 'latest':
+                        versions.append(tag_name)
+
+                # Check if there are more pages
+                if not data.get('next'):
+                    break
+
+                page += 1
+
+            return versions
+
+        except Exception as e:
+            raise Exception(f"Docker Hub API error: {str(e)}")
+
+class QuayAPI(ContainerRegistryAPI):
+    """Quay.io registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from Quay.io."""
+        try:
+            repo_name = f"{image.namespace}/{image.name}"
+            url = f"https://quay.io/api/v1/repository/{repo_name}/tag/";
+
+            params = {'limit': 100, 'page': 1}
+            versions = []
+
+            while True:
+                response = self.session.get(url, params=params, 
timeout=self.timeout)
+
+                if response.status_code != 200:
+                    if response.status_code == 404:
+                        return []
+                    response.raise_for_status()
+
+                data = response.json()
+
+                for tag_info in data.get('tags', []):
+                    tag_name = tag_info.get('name', '')
+                    if tag_name and tag_name != 'latest':
+                        versions.append(tag_name)
+
+                # Check if there are more pages
+                if not data.get('has_additional', False):
+                    break
+
+                params['page'] += 1
+                if params['page'] > 20:  # Prevent infinite loops
+                    break
+
+            return versions
+
+        except Exception as e:
+            raise Exception(f"Quay.io API error: {str(e)}")
+
+class GCRAPI(ContainerRegistryAPI):
+    """Google Container Registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from GCR."""
+        try:
+            # GCR uses Docker Registry v2 API
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"GCR API error: {str(e)}")
+
+class GHCRAPI(ContainerRegistryAPI):
+    """GitHub Container Registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from GHCR (GitHub Container Registry)."""
+        try:
+            # GHCR uses OCI Distribution API (Docker Registry v2 compatible)
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            # GHCR may require authentication for some repos, but we'll try 
anonymous first
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"GHCR API error: {str(e)}")
+
+class DockerV2RegistryAPI(ContainerRegistryAPI):
+    """Generic Docker Registry v2 API implementation for various registries."""
+
+    def __init__(self, timeout: int = 30, registry_name: str = "Docker 
Registry"):
+        super().__init__(timeout)
+        self.registry_name = registry_name
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from a Docker Registry v2 compatible 
registry."""
+        try:
+            # Build repository name
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"{self.registry_name} API error: {str(e)}")
+
+class ElasticRegistryAPI(ContainerRegistryAPI):
+    """Elastic Docker Registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from Elastic's Docker Registry."""
+        try:
+            # Elastic uses a standard Docker Registry v2 API
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"Elastic Registry API error: {str(e)}")
+
+class NVIDIARegistryAPI(ContainerRegistryAPI):
+    """NVIDIA Container Registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from NVIDIA Container Registry."""
+        try:
+            # NVIDIA NGC uses a Docker Registry v2 compatible API
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"NVIDIA Registry API error: {str(e)}")
+
+class MicrosoftRegistryAPI(ContainerRegistryAPI):
+    """Microsoft Container Registry API implementation."""
+
+    def get_available_versions(self, image: ContainerImage) -> List[str]:
+        """Get available versions from Microsoft Container Registry."""
+        try:
+            # MCR uses Docker Registry v2 API
+            if image.namespace:
+                repo_name = f"{image.namespace}/{image.name}"
+            else:
+                repo_name = image.name
+
+            url = f"https://{image.registry}/v2/{repo_name}/tags/list";
+
+            response = self.session.get(url, timeout=self.timeout)
+
+            if response.status_code != 200:
+                if response.status_code == 404:
+                    return []
+                response.raise_for_status()
+
+            data = response.json()
+            versions = data.get('tags', [])
+
+            # Filter out 'latest' tag
+            return [v for v in versions if v != 'latest']
+
+        except Exception as e:
+            raise Exception(f"Microsoft Registry API error: {str(e)}")
+
+class ContainerVersionChecker:
+    """Main class for checking container versions."""
+
+    def __init__(self,
+                 include_prereleases: bool = False,
+                 registry_timeout: int = 30,
+                 verbose: bool = False):
+        self.include_prereleases = include_prereleases
+        self.verbose = verbose
+
+        # Initialize registry APIs
+        self.registry_apis = {
+            'docker.io': DockerHubAPI(registry_timeout),
+            'registry-1.docker.io': DockerHubAPI(registry_timeout),
+            'quay.io': QuayAPI(registry_timeout),
+            'gcr.io': GCRAPI(registry_timeout),
+            'mirror.gcr.io': GCRAPI(registry_timeout),
+            'ghcr.io': GHCRAPI(registry_timeout),
+            'icr.io': DockerV2RegistryAPI(registry_timeout, "IBM Container 
Registry"),
+            'docker.elastic.co': ElasticRegistryAPI(registry_timeout),
+            'nvcr.io': NVIDIARegistryAPI(registry_timeout),
+            'mcr.microsoft.com': MicrosoftRegistryAPI(registry_timeout),
+            'cr.weaviate.io': DockerV2RegistryAPI(registry_timeout, "Weaviate 
Container Registry"),
+        }
+
+    def parse_container_reference(self, container_ref: str) -> Tuple[str, str, 
str, str]:
+        """Parse container reference into registry, namespace, name, and 
version."""
+        # Handle cases like:
+        # - postgres:17.5-alpine
+        # - mirror.gcr.io/postgres:17.5-alpine
+        # - quay.io/strimzi/kafka:latest-kafka-3.9.1
+        # - mirror.gcr.io/confluentinc/cp-kafka:7.9.2
+
+        if '://' in container_ref:
+            # Remove protocol if present
+            container_ref = container_ref.split('://', 1)[1]
+
+        # Split by ':'
+        parts = container_ref.rsplit(':', 1)
+        if len(parts) != 2:
+            raise ValueError(f"Invalid container reference: {container_ref}")
+
+        image_part, version_part = parts
+
+        # Split image part by '/'
+        image_components = image_part.split('/')
+
+        if len(image_components) == 1:
+            # No registry specified, assume Docker Hub
+            registry = "docker.io"
+            namespace = ""
+            name = image_components[0]
+        elif len(image_components) == 2:
+            # Could be registry/image or namespace/image
+            if '.' in image_components[0] or image_components[0] in 
['localhost', 'mirror']:
+                # Looks like a registry
+                registry = image_components[0]
+                namespace = ""
+                name = image_components[1]
+            else:
+                # Looks like namespace/image on Docker Hub
+                registry = "docker.io"
+                namespace = image_components[0]
+                name = image_components[1]
+        elif len(image_components) == 3:
+            # registry/namespace/image
+            registry = image_components[0]
+            namespace = image_components[1]
+            name = image_components[2]
+        else:
+            # More complex case, treat everything except last as 
registry/namespace
+            registry = image_components[0]
+            namespace = '/'.join(image_components[1:-1])
+            name = image_components[-1]
+
+        return registry, namespace, name, version_part
+
+    def find_container_properties_files(self, base_path: str) -> List[str]:
+        """Find all container.properties files in the specified directory."""
+        properties_files = []
+        base_path = Path(base_path)
+
+        if self.verbose:
+            print(f"  Searching in: {base_path}")
+
+        # Directories to exclude from search
+        exclude_dirs = {'target', 'build', '.git', 'node_modules', 
'__pycache__'}
+
+        # Look for container.properties files anywhere in the directory tree
+        for properties_file in base_path.rglob("container.properties"):
+            # Skip files in excluded directories
+            if any(excluded in properties_file.parts for excluded in 
exclude_dirs):
+                if self.verbose:
+                    print(f"  Skipped (in excluded dir): {properties_file}")
+                continue
+
+            properties_files.append(str(properties_file))
+            if self.verbose:
+                print(f"  Found: {properties_file}")
+
+        # If no files found, also check for any .properties files that might 
contain container references
+        # If no container.properties files found, search for other patterns
+        if not properties_files:
+            if self.verbose:
+                print("  No container.properties files found, searching for 
alternatives...")
+
+            # Check for other common container property file patterns
+            search_patterns = [
+                "docker.properties",
+                "containers.properties",
+                "images.properties",
+                "testcontainers.properties"
+            ]
+
+            for pattern in search_patterns:
+                for properties_file in base_path.rglob(pattern):
+                    # Skip files in excluded directories
+                    if any(excluded in properties_file.parts for excluded in 
exclude_dirs):
+                        continue
+                    properties_files.append(str(properties_file))
+                    if self.verbose:
+                        print(f"  Found by pattern {pattern}: 
{properties_file}")
+
+            # If still nothing found, scan all .properties files for container 
references
+            if not properties_files:
+                if self.verbose:
+                    print("  No specific container property files found, 
scanning all .properties files...")
+
+                for properties_file in base_path.rglob("*.properties"):
+                    # Skip files in excluded directories
+                    if any(excluded in properties_file.parts for excluded in 
exclude_dirs):
+                        continue
+
+                    # Skip very large files to avoid performance issues
+                    try:
+                        file_size = properties_file.stat().st_size
+                        if file_size > 1024 * 1024:  # Skip files larger than 
1MB
+                            continue
+
+                        with open(properties_file, 'r', encoding='utf-8') as f:
+                            content = f.read()
+                            # Look for patterns that suggest container 
definitions
+                            if any(pattern in content.lower() for pattern in [
+                                '.container=', 'container.image=', '.image=',
+                                'docker.io', 'gcr.io', 'quay.io', 'ghcr.io', 
'icr.io',
+                                'mcr.microsoft.com', 'nvcr.io', 
'docker.elastic.co',
+                                'cr.weaviate.io', 'localhost:', 'registry'
+                            ]):
+                                properties_files.append(str(properties_file))
+                                if self.verbose:
+                                    print(f"  Found container references in: 
{properties_file}")
+                    except (UnicodeDecodeError, IOError, OSError):
+                        # Skip files that can't be read
+                        continue
+
+        return sorted(properties_files)
+
+    def parse_properties_file(self, file_path: str) -> List[ContainerImage]:
+        """Parse a container.properties file and extract container images."""
+        images = []
+
+        try:
+            with open(file_path, 'r', encoding='utf-8') as f:
+                content = f.read()
+
+            # Parse properties manually to handle comments
+            for line_num, line in enumerate(content.splitlines(), 1):
+                line = line.strip()
+
+                # Skip comments and empty lines
+                if not line or line.startswith('#') or line.startswith('##'):
+                    continue
+
+                # Look for property=value format
+                if '=' in line:
+                    key, value = line.split('=', 1)
+                    key = key.strip()
+                    value = value.strip()
+
+                    # Skip if value looks like a property reference
+                    if value.startswith('${') or not value:
+                        continue
+
+                    try:
+                        registry, namespace, name, current_version = 
self.parse_container_reference(value)
+
+                        image = ContainerImage(
+                            registry=registry,
+                            namespace=namespace,
+                            name=name,
+                            current_version=current_version,
+                            property_name=key,
+                            file_path=file_path
+                        )
+                        images.append(image)
+
+                        if self.verbose:
+                            print(f"  Found: {key} = {value}")
+
+                    except ValueError as e:
+                        if self.verbose:
+                            print(f"  Warning: Could not parse {key}={value}: 
{e}")
+                        continue
+
+        except Exception as e:
+            print(f"Error reading {file_path}: {e}")
+            return []
+
+        return images
+
+    def check_image_versions(self, image: ContainerImage) -> 
VersionCheckResult:
+        """Check for newer versions of a container image."""
+        try:
+            # Get the appropriate API for the registry
+            registry_key = image.registry.lower()
+
+            # Handle registry aliases and special cases
+            if registry_key == "mirror.gcr.io":
+                registry_key = "gcr.io"
+            elif registry_key in ["", "docker.io"]:
+                registry_key = "docker.io"
+            elif registry_key == "registry-1.docker.io":
+                registry_key = "docker.io"
+
+            api = self.registry_apis.get(registry_key)
+            if not api:
+                return VersionCheckResult(
+                    image=image,
+                    available_versions=[],
+                    latest_version=None,
+                    newer_versions=[],
+                    is_latest=False,
+                    error=f"Unsupported registry: {image.registry}"
+                )
+
+            if self.verbose:
+                print(f"    Checking {image.full_image}...")
+
+            # Get available versions
+            available_versions = api.get_available_versions(image)
+
+            if not available_versions:
+                return VersionCheckResult(
+                    image=image,
+                    available_versions=[],
+                    latest_version=None,
+                    newer_versions=[],
+                    is_latest=True,
+                    error="No versions found in registry"
+                )
+
+            # Sort versions
+            def version_sort_key(v):
+                try:
+                    return version.parse(v)
+                except version.InvalidVersion:
+                    # For non-semantic versions, sort lexicographically
+                    return v
+
+            try:
+                sorted_versions = sorted(available_versions, 
key=version_sort_key, reverse=True)
+            except:
+                # If sorting fails, use lexicographic sort
+                sorted_versions = sorted(available_versions, reverse=True)
+
+            # Find newer versions
+            newer_versions = []
+            current_ver = image.current_version
+
+            for ver in sorted_versions:
+                try:
+                    if version.parse(ver) > version.parse(current_ver):
+                        if self.include_prereleases or not 
version.parse(ver).is_prerelease:
+                            newer_versions.append(ver)
+                except version.InvalidVersion:
+                    # For non-semantic versions, do string comparison
+                    if ver > current_ver:
+                        newer_versions.append(ver)
+
+            latest_version = sorted_versions[0] if sorted_versions else None
+            is_latest = current_ver == latest_version or len(newer_versions) 
== 0
+
+            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
+            )
+
+        except Exception as e:
+            return VersionCheckResult(
+                image=image,
+                available_versions=[],
+                latest_version=None,
+                newer_versions=[],
+                is_latest=False,
+                error=str(e)
+            )
+
+    def run_check(self, scan_path: str) -> List[VersionCheckResult]:
+        """Run the version check on all container.properties files."""
+        print(f"šŸ” Scanning for container.properties files in {scan_path}...")
+
+        properties_files = self.find_container_properties_files(scan_path)
+
+        if not properties_files:
+            print("āŒ No container.properties files found!")
+            return []
+
+        print(f"šŸ“ Found {len(properties_files)} container.properties files")
+
+        all_images = []
+
+        # Parse all properties files
+        for file_path in properties_files:
+            if self.verbose:
+                print(f"\nšŸ“„ Parsing {file_path}...")
+
+            images = self.parse_properties_file(file_path)
+            all_images.extend(images)
+
+        if not all_images:
+            print("āŒ No container images found in properties files!")
+            return []
+
+        print(f"🐳 Found {len(all_images)} container images")
+        print("🌐 Checking for newer versions...")
+
+        # Check versions for all images
+        results = []
+        for i, image in enumerate(all_images, 1):
+            print(f"  [{i}/{len(all_images)}] {image.full_image}")
+            result = self.check_image_versions(image)
+            results.append(result)
+            time.sleep(0.1)  # Be nice to APIs
+
+        return results
+
+def print_registry_summary(results: List[VersionCheckResult]):
+    """Print a summary of registries used."""
+    registry_counts = {}
+    for result in results:
+        registry = result.image.registry
+        if registry in registry_counts:
+            registry_counts[registry] += 1
+        else:
+            registry_counts[registry] = 1
+
+    if registry_counts:
+        print(f"\n{Style.BRIGHT}🌐 REGISTRY USAGE SUMMARY{Style.RESET_ALL}")
+        print("-" * 35)
+        for registry, count in sorted(registry_counts.items(), key=lambda x: 
x[1], reverse=True):
+            print(f"  {Fore.CYAN}{registry:<25}{Style.RESET_ALL} {count:>3} 
images")
+
+def print_results(results: List[VersionCheckResult], verbose: bool = False):
+    """Print the results in a human-readable format."""
+
+    # 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]
+    errors = [r for r in results if r.error]
+
+    print(f"\n{Style.BRIGHT}šŸ“Š VERSION CHECK SUMMARY{Style.RESET_ALL}")
+    print("=" * 50)
+
+    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}")
+    print(f"{Fore.RED}āŒ Errors: {len(errors)}{Style.RESET_ALL}")
+
+    if outdated:
+        print(f"\n{Style.BRIGHT}{Fore.YELLOW}šŸ“¦ OUTDATED 
IMAGES{Style.RESET_ALL}")
+        print("-" * 30)
+
+        for result in sorted(outdated, key=lambda x: len(x.newer_versions), 
reverse=True):
+            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.GREEN}{result.latest_version}{Style.RESET_ALL}")
+
+            if len(result.newer_versions) > 1:
+                newer_display = result.newer_versions[:3]
+                if len(result.newer_versions) > 3:
+                    newer_display.append(f"... (+{len(result.newer_versions) - 
3} more)")
+                print(f"  Newer:   {Fore.GREEN}{', 
'.join(newer_display)}{Style.RESET_ALL}")
+
+    if errors:
+        print(f"\n{Style.BRIGHT}{Fore.RED}āŒ ERRORS{Style.RESET_ALL}")
+        print("-" * 15)
+
+        for result in errors:
+            image = result.image
+            print(f"\n{Fore.CYAN}{image.property_name}{Style.RESET_ALL}")
+            print(f"  Image: {image.full_image}")
+            print(f"  Error: {Fore.RED}{result.error}{Style.RESET_ALL}")
+
+    if verbose and up_to_date:
+        print(f"\n{Style.BRIGHT}{Fore.GREEN}āœ… UP TO DATE 
IMAGES{Style.RESET_ALL}")
+        print("-" * 25)
+
+        for result in up_to_date:
+            image = result.image
+            print(f"  {image.property_name}: {image.current_version}")
+
+def output_json(results: List[VersionCheckResult]) -> str:
+    """Output results in JSON format."""
+    json_results = []
+
+    for result in results:
+        json_result = {
+            'image': asdict(result.image),
+            'available_versions': result.available_versions,
+            'latest_version': result.latest_version,
+            'newer_versions': result.newer_versions,
+            'is_latest': result.is_latest,
+            'error': result.error
+        }
+        json_results.append(json_result)
+
+    return json.dumps(json_results, indent=2)
+
+def main():
+    parser = argparse.ArgumentParser(
+        description='Check for newer versions of container images in Apache 
Camel test-infra',
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+    # Basic usage
+    python3 check-container-versions.py
+
+    # Verbose output
+    python3 check-container-versions.py --verbose
+
+    # JSON output for automation
+    python3 check-container-versions.py --json
+
+    # Include pre-release versions
+    python3 check-container-versions.py --check-prereleases
+        """
+    )
+
+    parser.add_argument(
+        '--verbose', '-v',
+        action='store_true',
+        help='Enable verbose output'
+    )
+
+    parser.add_argument(
+        '--json',
+        action='store_true',
+        help='Output results in JSON format'
+    )
+
+    parser.add_argument(
+        '--check-prereleases',
+        action='store_true',
+        help='Include pre-release versions in checks'
+    )
+
+    parser.add_argument(
+        '--registry-timeout',
+        type=int,
+        default=30,
+        help='Registry API timeout in seconds (default: 30)'
+    )
+
+    parser.add_argument(
+        '--test-infra-path',
+        type=str,
+        default=None,
+        help='Path to test-infra directory or any directory containing 
container.properties files (default: current directory)'
+    )
+
+    parser.add_argument(
+        '--scan-path',
+        type=str,
+        default=None,
+        help='Alias for --test-infra-path (scan any directory for 
container.properties files)'
+    )
+
+    args = parser.parse_args()
+
+    # Determine scan path (support both --test-infra-path and --scan-path)
+    scan_path = args.test_infra_path or args.scan_path
+
+    if scan_path:
+        # Use specified path (convert to absolute path)
+        scan_path = os.path.abspath(os.path.expanduser(scan_path))
+        if not os.path.exists(scan_path):
+            print(f"āŒ Specified path does not exist: {scan_path}")
+            sys.exit(1)
+        if not os.path.isdir(scan_path):
+            print(f"āŒ Specified path is not a directory: {scan_path}")
+            sys.exit(1)
+    else:
+        # Use current working directory
+        scan_path = os.getcwd()
+
+    # Convert to absolute path for consistent behavior
+    scan_path = os.path.abspath(scan_path)
+
+    if not args.json:
+        print(f"{Style.BRIGHT}🐳 Apache Camel Test Infrastructure Container 
Version Checker{Style.RESET_ALL}")
+        print("=" * 60)
+
+    # Create checker and run
+    checker = ContainerVersionChecker(
+        include_prereleases=args.check_prereleases,
+        registry_timeout=args.registry_timeout,
+        verbose=args.verbose
+    )
+
+    try:
+        results = checker.run_check(scan_path)
+
+        if args.json:
+            print(output_json(results))
+        else:
+            print_results(results, args.verbose)
+
+            # Show registry usage summary if verbose
+            if args.verbose:
+                print_registry_summary(results)
+
+            # Exit with non-zero if there are outdated images
+            outdated_count = len([r for r in results if not r.is_latest and 
not r.error and r.newer_versions])
+            if outdated_count > 0:
+                print(f"\nšŸ’” Found {outdated_count} outdated images. Consider 
updating!")
+                sys.exit(1)
+            else:
+                print(f"\nšŸŽ‰ All images are up to date!")
+
+    except KeyboardInterrupt:
+        print(f"\n{Fore.YELLOW}āš ļø  Interrupted by user{Style.RESET_ALL}")
+        sys.exit(130)
+    except Exception as e:
+        if args.json:
+            print(json.dumps({"error": str(e)}, indent=2))
+        else:
+            print(f"{Fore.RED}āŒ Error: {e}{Style.RESET_ALL}")
+        sys.exit(1)
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/.github/workflows/check-container-versions.yml 
b/.github/workflows/check-container-versions.yml
new file mode 100644
index 000000000000..d5e977929cbd
--- /dev/null
+++ b/.github/workflows/check-container-versions.yml
@@ -0,0 +1,331 @@
+#
+# 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.
+#
+
+name: Container Version Upgrade
+
+on:
+  schedule:
+    # Run every Monday at 6:00 AM UTC
+    - cron: '0 6 * * MON'
+  workflow_dispatch:
+    inputs:
+      scan_path:
+        description: 'Path to scan for container.properties files'
+        required: false
+        default: 'test-infra'
+      check_prereleases:
+        description: 'Include pre-release versions'
+        required: false
+        type: boolean
+        default: false
+
+jobs:
+  upgrade-container-versions:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+      pull-requests: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.11'
+          cache: 'pip'
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install requests packaging colorama
+
+      - name: Check container versions
+        id: version_check
+        run: |
+          SCAN_PATH="${{ github.event.inputs.scan_path || 'test-infra' }}"
+          PRERELEASE_FLAG=""
+
+          if [[ "${{ github.event.inputs.check_prereleases }}" == "true" ]]; 
then
+            PRERELEASE_FLAG="--check-prereleases"
+          fi
+
+          echo "Scanning path: $SCAN_PATH"
+
+          # Run the version checker and capture output
+          if python3 
./github/actions/check-container-upgrade/check-container-versions.py 
--scan-path "$SCAN_PATH" $PRERELEASE_FLAG --json > versions.json; then
+            echo "check_passed=true" >> $GITHUB_OUTPUT
+            echo "outdated_count=0" >> $GITHUB_OUTPUT
+          else
+            echo "check_passed=false" >> $GITHUB_OUTPUT
+
+            # Count outdated images
+            OUTDATED_COUNT=$(jq '[.[] | select(.is_latest == false and 
.newer_versions != null and (.newer_versions | length) > 0)] | length' 
versions.json)
+            echo "outdated_count=$OUTDATED_COUNT" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Update container.properties files
+        if: steps.version_check.outputs.check_passed == 'false'
+        id: update_files
+        run: |
+          # Create a script to update the properties files
+          cat > update_versions.py << 'SCRIPT_EOF'
+          import json
+          import re
+          import sys
+
+          # Load the version check results
+          with open('versions.json', 'r') as f:
+              results = json.load(f)
+
+          updated_files = []
+          updates = []
+
+          for result in results:
+              # Skip if already latest or has errors
+              if result.get('is_latest') or result.get('error'):
+                  continue
+
+              newer_versions = result.get('newer_versions', [])
+              if not newer_versions:
+                  continue
+
+              image = result['image']
+              file_path = image['file_path']
+              property_name = image['property_name']
+              current_version = image['current_version']
+              latest_version = result['latest_version']
+
+              # Read the properties file
+              try:
+                  with open(file_path, 'r', encoding='utf-8') as f:
+                      content = f.read()
+
+                  # Build the full image reference for find/replace
+                  registry = image['registry']
+                  namespace = image['namespace']
+                  name = image['name']
+
+                  # Construct the image path
+                  if namespace:
+                      image_path = f"{registry}/{namespace}/{name}" if 
registry not in ['docker.io', ''] else f"{namespace}/{name}"
+                  else:
+                      image_path = f"{registry}/{name}" if registry not in 
['docker.io', ''] else name
+
+                  # Create the old and new references
+                  old_ref = f"{image_path}:{current_version}"
+                  new_ref = f"{image_path}:{latest_version}"
+
+                  # Also handle case where registry might be implicit
+                  if registry in ['docker.io', '']:
+                      # Try both with and without explicit registry
+                      old_pattern = 
f"(docker\\.io/)?{re.escape(image_path)}:{re.escape(current_version)}"
+                      new_content = re.sub(old_pattern, new_ref, content)
+                  else:
+                      # Direct replacement
+                      new_content = content.replace(old_ref, new_ref)
+
+                  if new_content != content:
+                      # Write the updated content
+                      with open(file_path, 'w', encoding='utf-8') as f:
+                          f.write(new_content)
+
+                      updated_files.append(file_path)
+                      updates.append({
+                          'property': property_name,
+                          'file': file_path,
+                          'old_version': current_version,
+                          'new_version': latest_version,
+                          'image_name': image_path
+                      })
+
+                      print(f"āœ… Updated {property_name}: {current_version} → 
{latest_version}")
+                  else:
+                      print(f"āš ļø  Could not update {property_name} in 
{file_path}")
+
+              except Exception as e:
+                  print(f"āŒ Error updating {file_path}: {e}")
+                  continue
+
+          # Save the updates for PR body
+          with open('updates.json', 'w') as f:
+              json.dump(updates, f, indent=2)
+
+          print(f"\nTotal files updated: {len(updated_files)}")
+          sys.exit(0 if updated_files else 1)
+          SCRIPT_EOF
+
+          # Run the update script
+          if python3 update_versions.py; then
+            echo "updates_made=true" >> $GITHUB_OUTPUT
+          else
+            echo "updates_made=false" >> $GITHUB_OUTPUT
+          fi
+
+      - name: Generate PR description
+        if: steps.update_files.outputs.updates_made == 'true'
+        id: pr_description
+        run: |
+          # Generate PR body
+          cat > pr_body.md << 'EOF'
+          This PR updates outdated container images in test-infra to their 
latest versions.
+
+          ## Updated Images
+
+          EOF
+
+          # Add details from updates.json
+          python3 << 'PYTHON_EOF'
+          import json
+
+          with open('updates.json', 'r') as f:
+              updates = json.load(f)
+
+          for update in updates:
+              print(f"### {update['property']}")
+              print(f"- **Image**: `{update['image_name']}`")
+              print(f"- **File**: `{update['file']}`")
+              print(f"- **Old version**: `{update['old_version']}`")
+              print(f"- **New version**: `{update['new_version']}`")
+              print()
+          PYTHON_EOF >> pr_body.md
+
+          cat >> pr_body.md << 'EOF'
+
+          ## Verification
+
+          Please verify:
+          - [ ] All container image versions are compatible with existing tests
+          - [ ] No breaking changes in the updated versions
+          - [ ] Tests pass with the new versions
+
+          ---
+
+          This PR was automatically created by the [Container Version Upgrade 
workflow](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ 
github.run_id }}).
+          EOF
+
+          # Set output for PR body
+          {
+            echo 'pr_body<<PR_BODY_EOF'
+            cat pr_body.md
+            echo 'PR_BODY_EOF'
+          } >> $GITHUB_OUTPUT
+
+          # Generate commit message
+          UPDATE_COUNT=$(jq 'length' updates.json)
+          echo "commit_message=chore(test-infra): upgrade container images 
($UPDATE_COUNT images)" >> $GITHUB_OUTPUT
+
+      - name: Create Pull Request
+        if: steps.update_files.outputs.updates_made == 'true'
+        id: create_pr
+        uses: peter-evans/create-pull-request@v6
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+          commit-message: |
+            ${{ steps.pr_description.outputs.commit_message }}
+
+            Updated container images to latest versions:
+            ${{ steps.pr_description.outputs.pr_body }}
+
+            šŸ¤– Generated with Claude Code
+          branch: automated/container-version-upgrade-${{ github.run_number }}
+          delete-branch: true
+          title: 'chore(test-infra): Upgrade container images to latest 
versions'
+          body: ${{ steps.pr_description.outputs.pr_body }}
+          labels: |
+            dependencies
+            container-images
+            automated
+          draft: false
+
+      - name: Upload results artifact
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: container-version-check-results
+          path: |
+            versions.json
+            updates.json
+            pr_body.md
+          retention-days: 30
+
+      - name: Job summary
+        if: always()
+        run: |
+          if [[ -f pr_body.md ]]; then
+            cat pr_body.md >> $GITHUB_STEP_SUMMARY
+            if [[ -n "${{ steps.create_pr.outputs.pull-request-number }}" ]]; 
then
+              echo "" >> $GITHUB_STEP_SUMMARY
+              echo "šŸ“¬ **Pull Request Created**: #${{ 
steps.create_pr.outputs.pull-request-number }}" >> $GITHUB_STEP_SUMMARY
+              echo "šŸ”— **URL**: ${{ steps.create_pr.outputs.pull-request-url 
}}" >> $GITHUB_STEP_SUMMARY
+            fi
+          else
+            echo "āœ… All container images are up to date!" >> 
$GITHUB_STEP_SUMMARY
+          fi
+
+      - name: Comment on PR with details
+        if: steps.create_pr.outputs.pull-request-number != ''
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const fs = require('fs');
+
+            // Read the versions.json for additional context
+            const versions = JSON.parse(fs.readFileSync('versions.json', 
'utf8'));
+            const updates = JSON.parse(fs.readFileSync('updates.json', 
'utf8'));
+
+            let comment = '## Container Update Details\n\n';
+            comment += `Total images checked: ${versions.length}\n`;
+            comment += `Images updated: ${updates.length}\n\n`;
+
+            comment += '### Registry Distribution\n\n';
+            const registries = {};
+            updates.forEach(u => {
+              const registry = u.image_name.split('/')[0];
+              registries[registry] = (registries[registry] || 0) + 1;
+            });
+
+            for (const [registry, count] of Object.entries(registries)) {
+              comment += `- **${registry}**: ${count} image(s)\n`;
+            }
+
+            comment += '\n### Testing Recommendations\n\n';
+            comment += 'Run the following to verify the updates:\n';
+            comment += '```bash\n';
+            comment += 'mvn clean verify -pl ';
+
+            // Extract unique modules from file paths
+            const modules = new Set();
+            updates.forEach(u => {
+              const match = 
u.file.match(/test-infra\/(camel-test-infra-[^\/]+)/);
+              if (match) {
+                modules.add(match[1]);
+              }
+            });
+
+            comment += Array.from(modules).join(',');
+            comment += '\n```\n';
+
+            await github.rest.issues.createComment({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: ${{ steps.create_pr.outputs.pull-request-number }},
+              body: comment
+            });

Reply via email to