From ab04b17c47707b9eebc20c819559226d730af936 Mon Sep 17 00:00:00 2001 From: Silvia Tarabova Date: Wed, 10 Jun 2026 14:20:51 +0200 Subject: [PATCH 1/2] feat(info_collector): add test to fetch versions for tools in cluster Signed-off-by: Silvia Tarabova --- testsuite/component_metadata.py | 31 +++- testsuite/container_image_versions.py | 255 ++++++++++++++++++++++++++ testsuite/tests/info_collector.py | 49 +++++ 3 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 testsuite/container_image_versions.py diff --git a/testsuite/component_metadata.py b/testsuite/component_metadata.py index 5ad07684..bf267bdc 100644 --- a/testsuite/component_metadata.py +++ b/testsuite/component_metadata.py @@ -102,6 +102,14 @@ def get_kubernetes_version(project) -> Optional[str]: @staticmethod def get_component_images(project) -> list[tuple]: """Get container images from pods in a namespace using openshift_client.""" + return [(name, tag, image) for name, tag, image, _ in ReportPortalMetadataCollector.get_pod_images(project)] + + @staticmethod + def get_pod_images(project) -> list[tuple[str, str, str, str]]: + """Get container images from pods with resolved image digests from status. + + Returns list of (name, tag, full_image, digest) tuples. + """ images = [] try: with project.context: @@ -109,6 +117,11 @@ def get_component_images(project) -> list[tuple]: seen = set() for pod in pods: + status_map = {} + for cs in pod.model.status.containerStatuses or []: + if cs.name and cs.imageID: + status_map[cs.name] = cs.imageID + for container in pod.model.spec.containers: image = container.image if not image: @@ -117,14 +130,18 @@ def get_component_images(project) -> list[tuple]: if normalised_image in seen: continue seen.add(normalised_image) - image_name = normalised_image.split("/")[-1] - if ":" in image_name: - name, tag = image_name.rsplit(":", 1) - images.append((name, tag, image)) + + image_id = str(status_map.get(container.name, "")) + digest = image_id.split("@", 1)[1] if "@" in image_id else "" + + name = normalised_image.split("/")[-1] + if ":" in name: + name, tag = name.rsplit(":", 1) else: - images.append((image_name, None, image)) - except (oc.OpenShiftPythonException, AttributeError, KeyError, IndexError, ValueError) as e: - logger.warning("Failed to get images from %s: %s", project, e) + tag = "" + images.append((name, tag, image, digest)) + except (oc.OpenShiftPythonException, AttributeError, KeyError, IndexError, ValueError) as exc: + logger.warning("Failed to get pod images from %s: %s", project, exc) return images diff --git a/testsuite/container_image_versions.py b/testsuite/container_image_versions.py new file mode 100644 index 00000000..d47db2d1 --- /dev/null +++ b/testsuite/container_image_versions.py @@ -0,0 +1,255 @@ +"""Container registry client for resolving image digests to version tags.""" + +import logging +import re +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +SEMVER_PATTERN = re.compile(r"^v?\d+\.\d+\.\d+$") + +MANIFEST_ACCEPT = ", ".join( + [ + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.manifest.v1+json", + ] +) + +MANIFEST_LIST_TYPES = { + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", +} + +_AUTH_URLS = { + "quay.io": "https://quay.io/v2/auth?service=quay.io&scope=repository:{repo}:pull", + "ghcr.io": "https://ghcr.io/token?service=ghcr.io&scope=repository:{repo}:pull", +} + +_REGISTRY_ALIASES = { + "docker.dragonflydb.io": "ghcr.io", +} + +MAX_TAGS_TO_CHECK = 50 + +_REDHAT_CATALOG_API = "https://catalog.redhat.com/api/containers/v1/images" +_REDHAT_REGISTRIES = {"registry.redhat.io", "registry.access.redhat.com"} +REDHAT_VERSION_PATTERN = re.compile(r"^v?\d+\.\d+(\.\d+)?(-\d+)?$") + + +def _version_sort_key(tag): + """Parse a version tag (e.g., '1.2.3', '26.6-3') into a tuple for sorting.""" + base = tag.lstrip("v").split("-")[0] + suffix = tag.split("-")[1] if "-" in tag else "0" + parts = [int(x) for x in base.split(".")] + while len(parts) < 3: + parts.append(0) + return tuple(parts) + (int(suffix),) + + +class ContainerRegistryResolver: + """Resolves container image digests to semver version tags via registry APIs.""" + + def __init__(self, timeout: float = 10.0): + self._client = httpx.Client(timeout=timeout, follow_redirects=True) + self._tokens: dict[tuple[str, str], Optional[str]] = {} + self._httpx_logger = logging.getLogger("httpx") + self._httpx_log_level = self._httpx_logger.level + + def close(self): + """Close the HTTP client.""" + self._client.close() + + def __enter__(self): + self._httpx_logger.setLevel(logging.WARNING) + return self + + def __exit__(self, *args): + self._httpx_logger.setLevel(self._httpx_log_level) + self.close() + + def resolve_digest_to_tag(self, image_ref: str, digest: str) -> Optional[str]: + """Resolve an image digest to its semver version tag. + + Args: + image_ref: Full image reference (e.g., "quay.io/keycloak/keycloak:latest"). + digest: Image digest (e.g., "sha256:abc123..."). + + Returns: + Matched semver tag or None. + """ + if not digest: + return None + + try: + registry, repository = self._parse_image_ref(image_ref) + except ValueError: + logger.warning("Cannot parse image reference: %s", image_ref) + return None + + if registry in _REDHAT_REGISTRIES: + return self._resolve_via_redhat_catalog(repository, digest) + + token = self._get_auth_token(registry, repository) + tags = self._fetch_semver_tags(registry, repository, token) + if not tags: + logger.debug("No semver tags found for %s/%s", registry, repository) + return None + + for tag in tags[:MAX_TAGS_TO_CHECK]: + if self._digest_matches(registry, repository, tag, digest, token): + return tag + + logger.debug("No tag matched digest %s for %s/%s", digest[:20], registry, repository) + return None + + def _resolve_via_redhat_catalog(self, repository: str, digest: str) -> Optional[str]: + """Resolve a digest to a version tag using the Red Hat catalog API.""" + try: + resp = self._client.get( + _REDHAT_CATALOG_API, + params={ + "filter": f"repositories.repository=={repository}", + "page_size": 50, + "sort_by": "creation_date[desc]", + }, + ) + resp.raise_for_status() + data = resp.json().get("data", []) + except (httpx.HTTPError, KeyError) as exc: + logger.warning("Red Hat catalog query failed for %s: %s", repository, exc) + return None + + for image in data: + for repo in image.get("repositories", []): + if repo.get("repository") != repository: + continue + repo_digest = repo.get("manifest_list_digest") or repo.get("manifest_schema2_digest") + if repo_digest != digest: + continue + tags = [t["name"] for t in repo.get("tags", []) if REDHAT_VERSION_PATTERN.match(t.get("name", ""))] + if tags: + tags.sort(key=_version_sort_key, reverse=True) + return tags[0] + + logger.debug("No Red Hat catalog match for %s digest %s", repository, digest[:20]) + return None + + @staticmethod + def _parse_image_ref(image_ref: str) -> tuple[str, str]: + """Parse image reference into (registry, repository).""" + ref = image_ref.split("@")[0].split(":")[0] + + parts = ref.split("/") + if len(parts) < 2 or "." not in parts[0]: + raise ValueError(f"Cannot determine registry from: {image_ref}") + + registry = parts[0] + repository = "/".join(parts[1:]) + return registry, repository + + def _get_auth_token(self, registry: str, repository: str) -> Optional[str]: + """Get anonymous pull token for a registry.""" + actual_registry = _REGISTRY_ALIASES.get(registry, registry) + cache_key = (actual_registry, repository) + if cache_key in self._tokens: + return self._tokens[cache_key] + + auth_url_template = _AUTH_URLS.get(actual_registry) + if not auth_url_template: + self._tokens[cache_key] = None + return None + + try: + resp = self._client.get(auth_url_template.format(repo=repository)) + resp.raise_for_status() + data = resp.json() + token = data.get("token") or data.get("access_token") + self._tokens[cache_key] = token + return token + except (httpx.HTTPError, KeyError) as exc: + logger.warning("Failed to get auth token for %s/%s: %s", registry, repository, exc) + self._tokens[cache_key] = None + return None + + def _fetch_semver_tags(self, registry: str, repository: str, token: Optional[str]) -> list[str]: + """Fetch semver tags from registry, sorted newest-first.""" + actual_registry = _REGISTRY_ALIASES.get(registry, registry) + url: Optional[str] = f"https://{actual_registry}/v2/{repository}/tags/list?n=1000" + headers = self._auth_headers(token) + + all_tags: list[str] = [] + while url: + try: + resp = self._client.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() + all_tags.extend(data.get("tags") or []) + + url = self._next_page_url(resp, actual_registry) + except (httpx.HTTPError, KeyError) as exc: + logger.warning("Failed to fetch tags for %s/%s: %s", registry, repository, exc) + break + + semver_tags = [t for t in all_tags if SEMVER_PATTERN.match(t)] + semver_tags.sort(key=_version_sort_key, reverse=True) + return semver_tags + + def _digest_matches( + self, registry: str, repository: str, tag: str, target_digest: str, token: Optional[str] + ) -> bool: + """Check if a tag's manifest digest matches the target.""" + actual_registry = _REGISTRY_ALIASES.get(registry, registry) + url = f"https://{actual_registry}/v2/{repository}/manifests/{tag}" + headers = {**self._auth_headers(token), "Accept": MANIFEST_ACCEPT} + + try: + resp = self._client.get(url, headers=headers) + resp.raise_for_status() + except httpx.HTTPError as exc: + logger.debug("Failed to get manifest for %s:%s: %s", repository, tag, exc) + return False + + header_digest = resp.headers.get("docker-content-digest", "") + if header_digest == target_digest: + return True + + content_type = resp.headers.get("content-type", "") + if content_type in MANIFEST_LIST_TYPES: + return self._check_manifest_list(resp.json(), target_digest) + + return False + + @staticmethod + def _check_manifest_list(manifest_list: dict, target_digest: str) -> bool: + """Check if any platform manifest in a manifest list matches the target digest.""" + for manifest in manifest_list.get("manifests", []): + if manifest.get("digest") == target_digest: + return True + return False + + @staticmethod + def _auth_headers(token: Optional[str]) -> dict[str, str]: + """Build Authorization header dict.""" + if token: + return {"Authorization": f"Bearer {token}"} + return {} + + @staticmethod + def _next_page_url(resp: httpx.Response, registry: str) -> Optional[str]: + """Parse Link header for next page URL.""" + link = resp.headers.get("link", "") + if not link: + return None + + match = re.search(r"<([^>]+)>;\s*rel=\"next\"", link) + if not match: + return None + + next_url = match.group(1) + if next_url.startswith("http"): + return next_url + return f"https://{registry}{next_url}" diff --git a/testsuite/tests/info_collector.py b/testsuite/tests/info_collector.py index 6fde2d74..73ea3e22 100644 --- a/testsuite/tests/info_collector.py +++ b/testsuite/tests/info_collector.py @@ -25,6 +25,7 @@ from dynaconf import ValidationError from testsuite.component_metadata import ReportPortalMetadataCollector +from testsuite.container_image_versions import SEMVER_PATTERN, ContainerRegistryResolver from testsuite.template_utils import render_template from testsuite.config import settings @@ -119,6 +120,8 @@ def test_kuadrant_properties(record_testsuite_property): cluster_data[cluster_name] = [] kuadrant_images = ReportPortalMetadataCollector.get_component_images(project) for name, tag, full_image in kuadrant_images: + if "testsuite-pipelines-tools" in full_image: + continue if tag: cluster_data[cluster_name].append(f"{name}:{tag} ({full_image})") properties.append((name, tag)) @@ -129,6 +132,52 @@ def test_kuadrant_properties(record_testsuite_property): _record_unique(record_testsuite_property, properties) +def _resolve_tools_versions(tools_images): + """Resolve tool image digests to semver version tags.""" + results = [] + to_resolve = [] + for name, tag, full_image, digest in tools_images: + if tag and SEMVER_PATTERN.match(tag): + results.append((name, tag, full_image)) + else: + to_resolve.append((name, tag, full_image, digest)) + + if to_resolve: + try: + with ContainerRegistryResolver() as resolver: + for name, tag, full_image, digest in to_resolve: + resolved = resolver.resolve_digest_to_tag(full_image, digest) + results.append((name, resolved or tag, full_image)) + except Exception as exc: # pylint: disable=broad-except + logger.warning("Registry resolution failed: %s", exc) + for name, tag, full_image, _ in to_resolve: + results.append((name, tag, full_image)) + + return results + + +def test_tools_properties(record_testsuite_property): + """Record tools version properties from all clusters.""" + properties = [] + cluster_data = {} + for cluster_name, _, project in _all_cluster_projects("tools"): + if project is None: + cluster_data[cluster_name] = ["namespace 'tools' not found"] + continue + cluster_data[cluster_name] = [] + tools_images = ReportPortalMetadataCollector.get_pod_images(project) + resolved = _resolve_tools_versions(tools_images) + for name, version, full_image in resolved: + if version: + cluster_data[cluster_name].append(f"{name}:{version} ({full_image})") + properties.append((name, version)) + else: + cluster_data[cluster_name].append(full_image) + + _print_cluster_data(cluster_data) + _record_unique(record_testsuite_property, properties) + + def test_istio_properties(record_testsuite_property): """Record Istio installation type and metadata from all clusters.""" properties = [] From 26479c187faf4e37625e22b6b5d7bea4383a1faa Mon Sep 17 00:00:00 2001 From: Silvia Tarabova Date: Tue, 21 Jul 2026 15:11:03 +0200 Subject: [PATCH 2/2] refactor(info_collector): read tool versions from cluster Co-Authored-By: Claude Opus 4.6 Signed-off-by: Silvia Tarabova --- testsuite/component_metadata.py | 50 +++-- testsuite/container_image_versions.py | 255 -------------------------- testsuite/tests/info_collector.py | 64 ++++--- 3 files changed, 61 insertions(+), 308 deletions(-) delete mode 100644 testsuite/container_image_versions.py diff --git a/testsuite/component_metadata.py b/testsuite/component_metadata.py index bf267bdc..ead7a9bd 100644 --- a/testsuite/component_metadata.py +++ b/testsuite/component_metadata.py @@ -102,14 +102,6 @@ def get_kubernetes_version(project) -> Optional[str]: @staticmethod def get_component_images(project) -> list[tuple]: """Get container images from pods in a namespace using openshift_client.""" - return [(name, tag, image) for name, tag, image, _ in ReportPortalMetadataCollector.get_pod_images(project)] - - @staticmethod - def get_pod_images(project) -> list[tuple[str, str, str, str]]: - """Get container images from pods with resolved image digests from status. - - Returns list of (name, tag, full_image, digest) tuples. - """ images = [] try: with project.context: @@ -117,11 +109,6 @@ def get_pod_images(project) -> list[tuple[str, str, str, str]]: seen = set() for pod in pods: - status_map = {} - for cs in pod.model.status.containerStatuses or []: - if cs.name and cs.imageID: - status_map[cs.name] = cs.imageID - for container in pod.model.spec.containers: image = container.image if not image: @@ -131,20 +118,40 @@ def get_pod_images(project) -> list[tuple[str, str, str, str]]: continue seen.add(normalised_image) - image_id = str(status_map.get(container.name, "")) - digest = image_id.split("@", 1)[1] if "@" in image_id else "" - name = normalised_image.split("/")[-1] if ":" in name: name, tag = name.rsplit(":", 1) else: tag = "" - images.append((name, tag, image, digest)) + images.append((name, tag, image)) except (oc.OpenShiftPythonException, AttributeError, KeyError, IndexError, ValueError) as exc: logger.warning("Failed to get pod images from %s: %s", project, exc) return images + @staticmethod + def get_subscription_versions(project) -> dict[str, str]: + """Get installed operator versions from OLM Subscriptions in a namespace.""" + versions: dict[str, str] = {} + try: + with project.context: + subs = oc.selector("subscription.operators.coreos.com").objects() + for sub in subs: + try: + csv_name = sub.model.status.installedCSV + if not csv_name: + continue + except AttributeError: + continue + match = re.match(r"^(.+)\.v(.+)$", csv_name) + if match: + versions[match.group(1)] = f"v{match.group(2)}" + else: + versions[csv_name] = csv_name + except (oc.OpenShiftPythonException, AttributeError, KeyError, IndexError, ValueError) as exc: + logger.warning("Failed to get subscriptions from %s: %s", project, exc) + return versions + @staticmethod def get_istio_type(cluster) -> tuple[str, Optional[str]]: """Determine Istio installation type via GatewayClass and namespace from Istio CRs. @@ -172,9 +179,12 @@ def get_istio_metadata(project) -> dict[str, str]: with project.context: istio = oc.selector("istio").objects() if istio: - version = istio[0].model.spec.version - if version: - metadata["istio_version"] = version + try: + version = istio[0].model.spec.version + if version: + metadata["istio_version"] = version + except AttributeError: + pass pods = oc.selector("pods", labels={"app": "istiod"}).objects() if pods: diff --git a/testsuite/container_image_versions.py b/testsuite/container_image_versions.py deleted file mode 100644 index d47db2d1..00000000 --- a/testsuite/container_image_versions.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Container registry client for resolving image digests to version tags.""" - -import logging -import re -from typing import Optional - -import httpx - -logger = logging.getLogger(__name__) - -SEMVER_PATTERN = re.compile(r"^v?\d+\.\d+\.\d+$") - -MANIFEST_ACCEPT = ", ".join( - [ - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.oci.image.index.v1+json", - "application/vnd.docker.distribution.manifest.v2+json", - "application/vnd.oci.image.manifest.v1+json", - ] -) - -MANIFEST_LIST_TYPES = { - "application/vnd.docker.distribution.manifest.list.v2+json", - "application/vnd.oci.image.index.v1+json", -} - -_AUTH_URLS = { - "quay.io": "https://quay.io/v2/auth?service=quay.io&scope=repository:{repo}:pull", - "ghcr.io": "https://ghcr.io/token?service=ghcr.io&scope=repository:{repo}:pull", -} - -_REGISTRY_ALIASES = { - "docker.dragonflydb.io": "ghcr.io", -} - -MAX_TAGS_TO_CHECK = 50 - -_REDHAT_CATALOG_API = "https://catalog.redhat.com/api/containers/v1/images" -_REDHAT_REGISTRIES = {"registry.redhat.io", "registry.access.redhat.com"} -REDHAT_VERSION_PATTERN = re.compile(r"^v?\d+\.\d+(\.\d+)?(-\d+)?$") - - -def _version_sort_key(tag): - """Parse a version tag (e.g., '1.2.3', '26.6-3') into a tuple for sorting.""" - base = tag.lstrip("v").split("-")[0] - suffix = tag.split("-")[1] if "-" in tag else "0" - parts = [int(x) for x in base.split(".")] - while len(parts) < 3: - parts.append(0) - return tuple(parts) + (int(suffix),) - - -class ContainerRegistryResolver: - """Resolves container image digests to semver version tags via registry APIs.""" - - def __init__(self, timeout: float = 10.0): - self._client = httpx.Client(timeout=timeout, follow_redirects=True) - self._tokens: dict[tuple[str, str], Optional[str]] = {} - self._httpx_logger = logging.getLogger("httpx") - self._httpx_log_level = self._httpx_logger.level - - def close(self): - """Close the HTTP client.""" - self._client.close() - - def __enter__(self): - self._httpx_logger.setLevel(logging.WARNING) - return self - - def __exit__(self, *args): - self._httpx_logger.setLevel(self._httpx_log_level) - self.close() - - def resolve_digest_to_tag(self, image_ref: str, digest: str) -> Optional[str]: - """Resolve an image digest to its semver version tag. - - Args: - image_ref: Full image reference (e.g., "quay.io/keycloak/keycloak:latest"). - digest: Image digest (e.g., "sha256:abc123..."). - - Returns: - Matched semver tag or None. - """ - if not digest: - return None - - try: - registry, repository = self._parse_image_ref(image_ref) - except ValueError: - logger.warning("Cannot parse image reference: %s", image_ref) - return None - - if registry in _REDHAT_REGISTRIES: - return self._resolve_via_redhat_catalog(repository, digest) - - token = self._get_auth_token(registry, repository) - tags = self._fetch_semver_tags(registry, repository, token) - if not tags: - logger.debug("No semver tags found for %s/%s", registry, repository) - return None - - for tag in tags[:MAX_TAGS_TO_CHECK]: - if self._digest_matches(registry, repository, tag, digest, token): - return tag - - logger.debug("No tag matched digest %s for %s/%s", digest[:20], registry, repository) - return None - - def _resolve_via_redhat_catalog(self, repository: str, digest: str) -> Optional[str]: - """Resolve a digest to a version tag using the Red Hat catalog API.""" - try: - resp = self._client.get( - _REDHAT_CATALOG_API, - params={ - "filter": f"repositories.repository=={repository}", - "page_size": 50, - "sort_by": "creation_date[desc]", - }, - ) - resp.raise_for_status() - data = resp.json().get("data", []) - except (httpx.HTTPError, KeyError) as exc: - logger.warning("Red Hat catalog query failed for %s: %s", repository, exc) - return None - - for image in data: - for repo in image.get("repositories", []): - if repo.get("repository") != repository: - continue - repo_digest = repo.get("manifest_list_digest") or repo.get("manifest_schema2_digest") - if repo_digest != digest: - continue - tags = [t["name"] for t in repo.get("tags", []) if REDHAT_VERSION_PATTERN.match(t.get("name", ""))] - if tags: - tags.sort(key=_version_sort_key, reverse=True) - return tags[0] - - logger.debug("No Red Hat catalog match for %s digest %s", repository, digest[:20]) - return None - - @staticmethod - def _parse_image_ref(image_ref: str) -> tuple[str, str]: - """Parse image reference into (registry, repository).""" - ref = image_ref.split("@")[0].split(":")[0] - - parts = ref.split("/") - if len(parts) < 2 or "." not in parts[0]: - raise ValueError(f"Cannot determine registry from: {image_ref}") - - registry = parts[0] - repository = "/".join(parts[1:]) - return registry, repository - - def _get_auth_token(self, registry: str, repository: str) -> Optional[str]: - """Get anonymous pull token for a registry.""" - actual_registry = _REGISTRY_ALIASES.get(registry, registry) - cache_key = (actual_registry, repository) - if cache_key in self._tokens: - return self._tokens[cache_key] - - auth_url_template = _AUTH_URLS.get(actual_registry) - if not auth_url_template: - self._tokens[cache_key] = None - return None - - try: - resp = self._client.get(auth_url_template.format(repo=repository)) - resp.raise_for_status() - data = resp.json() - token = data.get("token") or data.get("access_token") - self._tokens[cache_key] = token - return token - except (httpx.HTTPError, KeyError) as exc: - logger.warning("Failed to get auth token for %s/%s: %s", registry, repository, exc) - self._tokens[cache_key] = None - return None - - def _fetch_semver_tags(self, registry: str, repository: str, token: Optional[str]) -> list[str]: - """Fetch semver tags from registry, sorted newest-first.""" - actual_registry = _REGISTRY_ALIASES.get(registry, registry) - url: Optional[str] = f"https://{actual_registry}/v2/{repository}/tags/list?n=1000" - headers = self._auth_headers(token) - - all_tags: list[str] = [] - while url: - try: - resp = self._client.get(url, headers=headers) - resp.raise_for_status() - data = resp.json() - all_tags.extend(data.get("tags") or []) - - url = self._next_page_url(resp, actual_registry) - except (httpx.HTTPError, KeyError) as exc: - logger.warning("Failed to fetch tags for %s/%s: %s", registry, repository, exc) - break - - semver_tags = [t for t in all_tags if SEMVER_PATTERN.match(t)] - semver_tags.sort(key=_version_sort_key, reverse=True) - return semver_tags - - def _digest_matches( - self, registry: str, repository: str, tag: str, target_digest: str, token: Optional[str] - ) -> bool: - """Check if a tag's manifest digest matches the target.""" - actual_registry = _REGISTRY_ALIASES.get(registry, registry) - url = f"https://{actual_registry}/v2/{repository}/manifests/{tag}" - headers = {**self._auth_headers(token), "Accept": MANIFEST_ACCEPT} - - try: - resp = self._client.get(url, headers=headers) - resp.raise_for_status() - except httpx.HTTPError as exc: - logger.debug("Failed to get manifest for %s:%s: %s", repository, tag, exc) - return False - - header_digest = resp.headers.get("docker-content-digest", "") - if header_digest == target_digest: - return True - - content_type = resp.headers.get("content-type", "") - if content_type in MANIFEST_LIST_TYPES: - return self._check_manifest_list(resp.json(), target_digest) - - return False - - @staticmethod - def _check_manifest_list(manifest_list: dict, target_digest: str) -> bool: - """Check if any platform manifest in a manifest list matches the target digest.""" - for manifest in manifest_list.get("manifests", []): - if manifest.get("digest") == target_digest: - return True - return False - - @staticmethod - def _auth_headers(token: Optional[str]) -> dict[str, str]: - """Build Authorization header dict.""" - if token: - return {"Authorization": f"Bearer {token}"} - return {} - - @staticmethod - def _next_page_url(resp: httpx.Response, registry: str) -> Optional[str]: - """Parse Link header for next page URL.""" - link = resp.headers.get("link", "") - if not link: - return None - - match = re.search(r"<([^>]+)>;\s*rel=\"next\"", link) - if not match: - return None - - next_url = match.group(1) - if next_url.startswith("http"): - return next_url - return f"https://{registry}{next_url}" diff --git a/testsuite/tests/info_collector.py b/testsuite/tests/info_collector.py index 73ea3e22..545814ff 100644 --- a/testsuite/tests/info_collector.py +++ b/testsuite/tests/info_collector.py @@ -25,7 +25,6 @@ from dynaconf import ValidationError from testsuite.component_metadata import ReportPortalMetadataCollector -from testsuite.container_image_versions import SEMVER_PATTERN, ContainerRegistryResolver from testsuite.template_utils import render_template from testsuite.config import settings @@ -132,45 +131,23 @@ def test_kuadrant_properties(record_testsuite_property): _record_unique(record_testsuite_property, properties) -def _resolve_tools_versions(tools_images): - """Resolve tool image digests to semver version tags.""" - results = [] - to_resolve = [] - for name, tag, full_image, digest in tools_images: - if tag and SEMVER_PATTERN.match(tag): - results.append((name, tag, full_image)) - else: - to_resolve.append((name, tag, full_image, digest)) - - if to_resolve: - try: - with ContainerRegistryResolver() as resolver: - for name, tag, full_image, digest in to_resolve: - resolved = resolver.resolve_digest_to_tag(full_image, digest) - results.append((name, resolved or tag, full_image)) - except Exception as exc: # pylint: disable=broad-except - logger.warning("Registry resolution failed: %s", exc) - for name, tag, full_image, _ in to_resolve: - results.append((name, tag, full_image)) - - return results - - def test_tools_properties(record_testsuite_property): """Record tools version properties from all clusters.""" + tools_ns = "tools" properties = [] cluster_data = {} - for cluster_name, _, project in _all_cluster_projects("tools"): + for cluster_name, _, project in _all_cluster_projects(tools_ns): if project is None: - cluster_data[cluster_name] = ["namespace 'tools' not found"] + cluster_data[cluster_name] = [f"namespace '{tools_ns}' not found"] continue cluster_data[cluster_name] = [] - tools_images = ReportPortalMetadataCollector.get_pod_images(project) - resolved = _resolve_tools_versions(tools_images) - for name, version, full_image in resolved: - if version: - cluster_data[cluster_name].append(f"{name}:{version} ({full_image})") - properties.append((name, version)) + tools_images = ReportPortalMetadataCollector.get_component_images(project) + for name, tag, full_image in tools_images: + if name not in {"jaeger", "redis", "dragonfly", "valkey"}: + continue + if tag: + cluster_data[cluster_name].append(f"{name}:{tag} ({full_image})") + properties.append((name, tag)) else: cluster_data[cluster_name].append(full_image) @@ -178,6 +155,27 @@ def test_tools_properties(record_testsuite_property): _record_unique(record_testsuite_property, properties) +def test_tools_operator_properties(record_testsuite_property): + """Record OLM operator version properties from all clusters.""" + namespaces = ["tools", "cert-manager-operator"] + properties = [] + cluster_data = {} + for cluster_name, cluster in ReportPortalMetadataCollector.get_cluster_configurations(): + cluster_data[cluster_name] = [] + for ns in namespaces: + project = cluster.change_project(ns) + if not project.connected: + cluster_data[cluster_name].append(f"namespace '{ns}' not found") + continue + versions = ReportPortalMetadataCollector.get_subscription_versions(project) + for name, version in versions.items(): + cluster_data[cluster_name].append(f"{name}:{version}") + properties.append((name, version)) + + _print_cluster_data(cluster_data) + _record_unique(record_testsuite_property, properties) + + def test_istio_properties(record_testsuite_property): """Record Istio installation type and metadata from all clusters.""" properties = []