From abad3a7fae568e8f8e2a1a3b154f625410e7d1d7 Mon Sep 17 00:00:00 2001 From: Silvia Tarabova Date: Mon, 18 May 2026 12:18:45 +0200 Subject: [PATCH 1/3] fix: collect launch properties when some cluster lacks Kuadrant Signed-off-by: Silvia Tarabova --- testsuite/component_metadata.py | 32 +++++++------------------ testsuite/kubernetes/client.py | 28 ++++++++++++++++++++++ testsuite/tests/conftest.py | 18 +++++++------- testsuite/tests/info_collector.py | 40 +++++++++++++++++++++---------- 4 files changed, 75 insertions(+), 43 deletions(-) diff --git a/testsuite/component_metadata.py b/testsuite/component_metadata.py index e4ad17a0a..7138c1886 100644 --- a/testsuite/component_metadata.py +++ b/testsuite/component_metadata.py @@ -21,13 +21,14 @@ def __init__(self): def collect_all_clusters(self): """Collect metadata from all configured clusters.""" - clusters_config = self._get_cluster_configurations() + clusters_config = self.get_cluster_configurations() for cluster_name, cluster_client in clusters_config: metadata = self._collect_single_cluster(cluster_client) if metadata: self.all_cluster_metadata[cluster_name] = metadata - def _get_cluster_configurations(self): + @staticmethod + def get_cluster_configurations(): """Get cluster configurations from settings.""" clusters_config = [("cluster1", settings["control_plane"]["cluster"])] if cluster2 := settings["control_plane"].get("cluster2"): @@ -38,14 +39,16 @@ def _get_cluster_configurations(self): def _collect_single_cluster(self, cluster_client): """Collect metadata for a single cluster.""" - project = cluster_client.change_project(settings["service_protection"]["system_project"]) - if not project.connected: + if not cluster_client.is_reachable: return None + project = cluster_client.change_project(settings["service_protection"]["system_project"]) + metadata = self._get_kuadrant_metadata(project) if project.connected else {"kuadrant_image": "not installed"} + return { - "metadata": self._get_kuadrant_metadata(project), + "metadata": metadata, "console_url": self._get_console_url(cluster_client.api_url), - "ocp_version": self.get_ocp_version(project), + "ocp_version": cluster_client.ocp_version, } @staticmethod @@ -78,23 +81,6 @@ def _get_console_url(api_url): return f"https://{console_hostname}" return api_url - @staticmethod - def get_ocp_version(project) -> Optional[str]: - """Retrieve and format OCP version from cluster.""" - try: - with project.context: - version_result = oc.selector("clusterversion").objects() - if version_result: - ocp_version = version_result[0].model.status.history[0].version - if ocp_version: - parts = ocp_version.split(".") - if len(parts) >= 2: - return f"{parts[0]}.{parts[1]}" - except (oc.OpenShiftPythonException, AttributeError, KeyError, IndexError, ValueError) as e: - logger.warning("Failed to get OCP version: %s", e) - - return None - @staticmethod def get_kubernetes_version(project) -> Optional[str]: """Run oc version and get the kubernetes version.""" diff --git a/testsuite/kubernetes/client.py b/testsuite/kubernetes/client.py index 88f175d3b..0210c6dba 100644 --- a/testsuite/kubernetes/client.py +++ b/testsuite/kubernetes/client.py @@ -1,6 +1,8 @@ """This module implements an KubernetesCLI interface using oc/kubectl binary commands.""" +import logging from functools import cached_property +from typing import Optional from urllib.parse import urlparse import tempfile import yaml @@ -14,6 +16,8 @@ from .deployment import Deployment from .secret import Secret +logger = logging.getLogger(__name__) + class KubernetesClient: """KubernetesClient is a helper class for invoking kubectl commands""" @@ -100,6 +104,30 @@ def connected(self): return False return True + @property + def is_reachable(self): + """Returns True if the cluster is reachable, without depending on any specific namespace.""" + try: + self.do_action("api-versions") + except OpenShiftPythonException as e: + logger.warning("Cluster is not reachable: %s", e) + return False + return True + + @property + def ocp_version(self) -> Optional[str]: + """Returns the OpenShift version (major.minor) or None if not available.""" + result = self.do_action( + "get", "clusterversion", "version", "-o", "jsonpath={.status.history[0].version}", auto_raise=False + ) + if result.status() != 0: + return None + version_str = result.out().strip() + parts = version_str.split(".") + if len(parts) >= 2: + return f"{parts[0]}.{parts[1]}" + return None + def get_secret(self, name): """Returns dict-like structure for accessing secret data""" with self.context: diff --git a/testsuite/tests/conftest.py b/testsuite/tests/conftest.py index 903533c41..5a22b8728 100644 --- a/testsuite/tests/conftest.py +++ b/testsuite/tests/conftest.py @@ -45,6 +45,10 @@ def pytest_runtest_setup(item): # error is raised during has_kuadrant() if item.config.getoption("--setup-plan"): return + + if item.fspath.basename == "info_collector.py": + return + marks = [i.name for i in item.iter_markers()] skip_or_fail = pytest.fail if item.config.getoption("--enforce") else pytest.skip standalone = item.config.getoption("--standalone") @@ -422,16 +426,14 @@ def dns_provider_secret(testconfig): @pytest.fixture(scope="session") -def openshift_version(cluster): +def openshift_version(testconfig): """Get OpenShift cluster version""" - result = cluster.do_action( - "get", "clusterversion", "version", "-o", "jsonpath={.status.desired.version}", auto_raise=False - ) - if result.status() != 0: + cluster = testconfig["control_plane"]["cluster"] + version = cluster.ocp_version + if version is None: return None - version_str = result.out().strip() - parts = version_str.split(".") - return tuple(int(p.split("-")[0]) for p in parts[:2]) # Convert "4.20.0" -> (4, 20) + parts = version.split(".") + return tuple(int(p.split("-")[0]) for p in parts[:2]) # Convert "4.20" -> (4, 20) @pytest.fixture(autouse=True) diff --git a/testsuite/tests/info_collector.py b/testsuite/tests/info_collector.py index 4939c60ee..79813e2a4 100644 --- a/testsuite/tests/info_collector.py +++ b/testsuite/tests/info_collector.py @@ -30,6 +30,16 @@ logger = logging.getLogger(__name__) + +def _first_connected(namespace): + """Return the first (cluster_client, project) connected to the given namespace, or (None, None).""" + for _, cluster in ReportPortalMetadataCollector.get_cluster_configurations(): + project = cluster.change_project(namespace) + if project.connected: + return cluster, project + return None, None + + pytestmark = pytest.mark.skipif( not os.environ.get("COLLECTOR_ENABLE"), reason="collector was not explicitly enabled", @@ -37,12 +47,13 @@ def gather_cluster_versions() -> dict: - """gather all particular versions into a dictionary""" - cluster_client = settings["control_plane"]["cluster"] - project = cluster_client.change_project(settings["service_protection"]["system_project"]) + """Gather cluster versions from the first cluster with Kuadrant system namespace.""" + cluster, project = _first_connected(settings["service_protection"]["system_project"]) + if project is None: + return {} return { "kubernetes": ReportPortalMetadataCollector.get_kubernetes_version(project), - "openshift": ReportPortalMetadataCollector.get_ocp_version(project), + "openshift": cluster.ocp_version, } @@ -79,17 +90,21 @@ def test_cluster_properties(record_testsuite_property): def test_kube_context(record_testsuite_property): - """Record current kube context""" - kube_context = settings["control_plane"]["cluster"].kubeconfig_path + """Record kube context from the first cluster with kuadrant-system.""" + cluster_client, _ = _first_connected(settings["service_protection"]["system_project"]) + if cluster_client is None: + return + kube_context = cluster_client.kubeconfig_path print(f"{kube_context=}") if kube_context: record_testsuite_property("kube_context", kube_context) def test_kuadrant_properties(record_testsuite_property): - """Record kuadrant related properties""" - cluster_client = settings["control_plane"]["cluster"] - project = cluster_client.change_project("kuadrant-system") + """Record kuadrant related properties from the first cluster with kuadrant-system.""" + _, project = _first_connected(settings["service_protection"]["system_project"]) + if project is None: + return kuadrant_images = ReportPortalMetadataCollector.get_component_images(project) if kuadrant_images: print(f"Kuadrant images: {kuadrant_images}") @@ -98,9 +113,10 @@ def test_kuadrant_properties(record_testsuite_property): def test_istio_properties(record_testsuite_property): - """Record Istio related properties""" - cluster_client = settings["control_plane"]["cluster"] - project = cluster_client.change_project("istio-system") + """Record Istio related properties from the first cluster with istio-system.""" + _, project = _first_connected("istio-system") + if project is None: + return istio_metadata = ReportPortalMetadataCollector.get_istio_metadata(project) for key, value in istio_metadata.items(): print(f"{key}: {value}") From 18da2c5cb6ca1f68f57c48392e1c5bdb2a373966 Mon Sep 17 00:00:00 2001 From: Silvia Tarabova Date: Tue, 19 May 2026 15:48:14 +0200 Subject: [PATCH 2/3] refactor: collect launch properties from all clusters Signed-off-by: Silvia Tarabova --- testsuite/component_metadata.py | 3 +- testsuite/tests/info_collector.py | 132 ++++++++++++++++++------------ 2 files changed, 79 insertions(+), 56 deletions(-) diff --git a/testsuite/component_metadata.py b/testsuite/component_metadata.py index 7138c1886..aa6a7cb14 100644 --- a/testsuite/component_metadata.py +++ b/testsuite/component_metadata.py @@ -116,13 +116,12 @@ def get_component_images(project) -> list[tuple]: if normalised_image in seen: continue seen.add(normalised_image) - print(f"{image=}") image_name = normalised_image.split("/")[-1] if ":" in image_name: name, tag = image_name.rsplit(":", 1) images.append((name, tag, image)) else: - logger.debug("Skipping image without tag: %s", image) + 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) diff --git a/testsuite/tests/info_collector.py b/testsuite/tests/info_collector.py index 79813e2a4..b2b0b95c0 100644 --- a/testsuite/tests/info_collector.py +++ b/testsuite/tests/info_collector.py @@ -30,31 +30,37 @@ logger = logging.getLogger(__name__) - -def _first_connected(namespace): - """Return the first (cluster_client, project) connected to the given namespace, or (None, None).""" - for _, cluster in ReportPortalMetadataCollector.get_cluster_configurations(): - project = cluster.change_project(namespace) - if project.connected: - return cluster, project - return None, None - - pytestmark = pytest.mark.skipif( not os.environ.get("COLLECTOR_ENABLE"), reason="collector was not explicitly enabled", ) -def gather_cluster_versions() -> dict: - """Gather cluster versions from the first cluster with Kuadrant system namespace.""" - cluster, project = _first_connected(settings["service_protection"]["system_project"]) - if project is None: - return {} - return { - "kubernetes": ReportPortalMetadataCollector.get_kubernetes_version(project), - "openshift": cluster.ocp_version, - } +def _all_cluster_projects(namespace): + """Yield (cluster_name, cluster_client, project or None) for each configured cluster.""" + for cluster_name, cluster in ReportPortalMetadataCollector.get_cluster_configurations(): + project = cluster.change_project(namespace) + yield cluster_name, cluster, (project if project.connected else None) + + +def _print_cluster_data(cluster_data): + """Print collected data per cluster.""" + for cluster_name, lines in cluster_data.items(): + print(f"\n{cluster_name}:") + for line in lines: + print(f" {line}") + + +def _record_unique(record_testsuite_property, properties): + """Record properties, only adding unique ones as attributes.""" + seen = set() + for key, value in properties: + if not value: + continue + if (key, value) not in seen: + seen.add((key, value)) + record_testsuite_property(key, value) + logger.info("recording property %s:%s", key, value) def get_cluster_information() -> dict: @@ -81,44 +87,62 @@ def test_launch_description(record_testsuite_property): def test_cluster_properties(record_testsuite_property): - """Collect cluster properties""" - cluster_versions = gather_cluster_versions() - for k, v in cluster_versions.items(): - print(f"recording property {k}:{v}") - if v: # filter out None values - record_testsuite_property(k, v) - - -def test_kube_context(record_testsuite_property): - """Record kube context from the first cluster with kuadrant-system.""" - cluster_client, _ = _first_connected(settings["service_protection"]["system_project"]) - if cluster_client is None: - return - kube_context = cluster_client.kubeconfig_path - print(f"{kube_context=}") - if kube_context: - record_testsuite_property("kube_context", kube_context) + """Collect cluster version properties from all clusters.""" + system_ns = settings["service_protection"]["system_project"] + properties = [] + cluster_data = {} + for cluster_name, cluster, project in _all_cluster_projects(system_ns): + if project is None: + cluster_data[cluster_name] = [f"namespace '{system_ns}' not found"] + continue + versions = { + "kubernetes": ReportPortalMetadataCollector.get_kubernetes_version(project), + "openshift": cluster.ocp_version, + } + cluster_data[cluster_name] = [f"{k}:{v}" for k, v in versions.items()] + for key, value in versions.items(): + properties.append((key, value)) + + _print_cluster_data(cluster_data) + _record_unique(record_testsuite_property, properties) def test_kuadrant_properties(record_testsuite_property): - """Record kuadrant related properties from the first cluster with kuadrant-system.""" - _, project = _first_connected(settings["service_protection"]["system_project"]) - if project is None: - return - kuadrant_images = ReportPortalMetadataCollector.get_component_images(project) - if kuadrant_images: - print(f"Kuadrant images: {kuadrant_images}") - for name, tag, _ in kuadrant_images: - record_testsuite_property(name, tag) + """Record kuadrant related properties from all clusters.""" + system_ns = settings["service_protection"]["system_project"] + properties = [] + cluster_data = {} + for cluster_name, _, project in _all_cluster_projects(system_ns): + if project is None: + cluster_data[cluster_name] = [f"namespace '{system_ns}' not found"] + continue + cluster_data[cluster_name] = [] + kuadrant_images = ReportPortalMetadataCollector.get_component_images(project) + for name, tag, full_image in kuadrant_images: + if tag: + cluster_data[cluster_name].append(f"{name}:{tag} ({full_image})") + properties.append((name, tag)) + 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 related properties from the first cluster with istio-system.""" - _, project = _first_connected("istio-system") - if project is None: - return - istio_metadata = ReportPortalMetadataCollector.get_istio_metadata(project) - for key, value in istio_metadata.items(): - print(f"{key}: {value}") - if key == "istio_version": - record_testsuite_property(key, value) + """Record Istio related properties from all clusters.""" + properties = [] + cluster_data = {} + for cluster_name, _, project in _all_cluster_projects("istio-system"): + if project is None: + cluster_data[cluster_name] = ["namespace 'istio-system' not found"] + continue + cluster_data[cluster_name] = [] + istio_metadata = ReportPortalMetadataCollector.get_istio_metadata(project) + for key, value in istio_metadata.items(): + cluster_data[cluster_name].append(f"{key}:{value}") + if key == "istio_version": + properties.append((key, value)) + + _print_cluster_data(cluster_data) + _record_unique(record_testsuite_property, properties) From b2c4bd05c417f6638ac8c9b65a5bd8334346af11 Mon Sep 17 00:00:00 2001 From: Silvia Tarabova Date: Mon, 1 Jun 2026 12:08:20 +0200 Subject: [PATCH 3/3] fix: address review feedback Signed-off-by: Silvia Tarabova --- testsuite/kubernetes/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/kubernetes/client.py b/testsuite/kubernetes/client.py index 0210c6dba..b1ff38bd3 100644 --- a/testsuite/kubernetes/client.py +++ b/testsuite/kubernetes/client.py @@ -114,7 +114,7 @@ def is_reachable(self): return False return True - @property + @cached_property def ocp_version(self) -> Optional[str]: """Returns the OpenShift version (major.minor) or None if not available.""" result = self.do_action(