You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ac...@apache.org on 2025/12/02 08:50:16 UTC
(camel) 01/02: Attempt at Check container upgrade
This is an automated email from the ASF dual-hosted git repository.
acosentino pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 8448daecea48110224939706f5eeb2c4d87f1440
Author: Andrea Cosentino <an...@gmail.com>
AuthorDate: Tue Dec 2 09:39:32 2025 +0100
Attempt at Check container upgrade
Signed-off-by: Andrea Cosentino <an...@gmail.com>
---
.../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
+ });