diff --git a/src/in_cluster_checks/domains/cluster_overview_domain.py b/src/in_cluster_checks/domains/cluster_overview_domain.py new file mode 100644 index 0000000..b603bd8 --- /dev/null +++ b/src/in_cluster_checks/domains/cluster_overview_domain.py @@ -0,0 +1,35 @@ +""" +Cluster overview rule domain. + +Collects informational, read-only snapshots describing what the cluster is +(architecture, versions, stack) rather than validating pass/fail conditions. +""" + +from typing import List + +from in_cluster_checks.core.domain import RuleDomain +from in_cluster_checks.rules.cluster_overview.architecture_overview import ClusterArchitectureOverview + + +class ClusterOverviewDomain(RuleDomain): + """ + Cluster overview domain. + + Groups informational collection rules that describe the cluster's + architecture: identity, topology, network, storage, and platform services. + """ + + def domain_name(self) -> str: + """Get domain name.""" + return "cluster_overview" + + def get_rule_classes(self) -> List[type]: + """ + Get list of cluster overview rules to run. + + Returns: + List of Rule classes + """ + return [ + ClusterArchitectureOverview, + ] diff --git a/src/in_cluster_checks/rules/cluster_overview/__init__.py b/src/in_cluster_checks/rules/cluster_overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/in_cluster_checks/rules/cluster_overview/architecture_overview.py b/src/in_cluster_checks/rules/cluster_overview/architecture_overview.py new file mode 100644 index 0000000..766722a --- /dev/null +++ b/src/in_cluster_checks/rules/cluster_overview/architecture_overview.py @@ -0,0 +1,215 @@ +""" +Cluster architecture overview collection. + +Collects a structured, read-only snapshot of what the cluster IS — identity, +topology, network, storage, identity providers, and installed operators — +via the cluster API. Contributed from the ocp-analyzer project. +""" + +from in_cluster_checks.core.rule import OrchestratorRule +from in_cluster_checks.core.rule_result import RuleResult +from in_cluster_checks.utils.enums import Objectives +from in_cluster_checks.utils.parsing_utils import parse_json + +VERSION_HISTORY_LIMIT = 12 + + +class ClusterArchitectureOverview(OrchestratorRule): + """Collect a read-only architecture overview of the cluster. + + Gathers cluster identity (version, channel, platform, base domain), + topology (nodes by role, control-plane topology), network (CNI type, + cluster/service CIDRs, MTU), storage classes, identity providers, and + installed operators into a structured INFO result. + + The ClusterVersion resource is required; every other section degrades + gracefully (e.g. RBAC denied, resource absent) so a partial overview + is still reported instead of failing the whole rule. + """ + + objective_hosts = [Objectives.ORCHESTRATOR] + unique_name = "cluster_architecture_overview" + title = "Cluster architecture overview" + supported_profiles = {"general"} + links = [ + "https://docs.openshift.com/container-platform/latest/architecture/architecture.html", + ] + + def run_rule(self) -> RuleResult: + """Collect all overview sections and return them as a structured INFO result.""" + infrastructure = self._get_resource(["infrastructure", "cluster"]) or {} + + overview = { + "cluster_identity": self._collect_cluster_identity(infrastructure), + "topology": self._collect_topology(infrastructure), + "network": self._collect_network(), + "storage": self._collect_storage(), + "identity_providers": self._collect_identity_providers(), + "operators": self._collect_operators(), + } + + return RuleResult.info(self._build_summary(overview), system_info=overview) + + def _get_resource(self, resource_args: list, required: bool = False) -> dict | None: + """Fetch a cluster resource as parsed JSON. + + Args: + resource_args: Arguments for `oc get` (e.g. ["network.config", "cluster"]) + required: If True, a failed command raises UnExpectedSystemOutput + (rule becomes SKIP); otherwise None is returned + + Returns: + Parsed resource dict, or None if unavailable and not required + """ + args = [*resource_args, "-o", "json"] + return_code, output, _ = self.oc_api.run_oc_command("get", args, timeout=45, raise_on_error=required) + if return_code != 0: + return None + return parse_json(output, f"oc get {' '.join(args)}", self.get_host_ip()) + + def _collect_cluster_identity(self, infrastructure: dict) -> dict: + """Collect version, channel, cluster ID, platform, and base domain.""" + cluster_version = self._get_resource(["clusterversion", "version"], required=True) + spec = cluster_version.get("spec", {}) + status = cluster_version.get("status", {}) + identity = { + "version": status.get("desired", {}).get("version"), + "channel": spec.get("channel"), + "cluster_id": spec.get("clusterID"), + "version_history": [entry.get("version") for entry in status.get("history", [])[:VERSION_HISTORY_LIMIT]], + } + + infra_status = infrastructure.get("status", {}) + identity["platform"] = infra_status.get("platformStatus", {}).get("type") + identity["infrastructure_name"] = infra_status.get("infrastructureName") + identity["api_server_url"] = infra_status.get("apiServerURL") + + dns_config = self._get_resource(["dns.config", "cluster"]) + if dns_config: + identity["base_domain"] = dns_config.get("spec", {}).get("baseDomain") + + return identity + + def _collect_topology(self, infrastructure: dict) -> dict: + """Collect node counts by role, control-plane topology, and node software versions.""" + infra_status = infrastructure.get("status", {}) + topology = { + "control_plane_topology": infra_status.get("controlPlaneTopology"), + "infrastructure_topology": infra_status.get("infrastructureTopology"), + } + + nodes = self._get_resource(["nodes"]) + if not nodes: + return topology + + items = nodes.get("items", []) + nodes_by_role = {} + kubelet_versions = set() + os_images = set() + for node in items: + for role in self._get_node_roles(node): + nodes_by_role[role] = nodes_by_role.get(role, 0) + 1 + node_info = node.get("status", {}).get("nodeInfo", {}) + if node_info.get("kubeletVersion"): + kubelet_versions.add(node_info["kubeletVersion"]) + if node_info.get("osImage"): + os_images.add(node_info["osImage"]) + + topology["node_count"] = len(items) + topology["nodes_by_role"] = nodes_by_role + topology["kubelet_versions"] = sorted(kubelet_versions) + topology["os_images"] = sorted(os_images) + return topology + + @staticmethod + def _get_node_roles(node: dict) -> list: + """Extract role names from a node's node-role.kubernetes.io/* labels.""" + labels = node.get("metadata", {}).get("labels", {}) + roles = [label.split("/", 1)[1] for label in labels if label.startswith("node-role.kubernetes.io/")] + return sorted(roles) if roles else ["unknown"] + + def _collect_network(self) -> dict: + """Collect CNI type, cluster/service CIDRs, and MTU.""" + network_config = self._get_resource(["network.config", "cluster"]) + if not network_config: + return {} + + status = network_config.get("status", {}) + return { + "network_type": status.get("networkType"), + "cluster_network": [entry.get("cidr") for entry in status.get("clusterNetwork", [])], + "service_network": status.get("serviceNetwork", []), + "cluster_network_mtu": status.get("clusterNetworkMTU"), + } + + def _collect_storage(self) -> dict: + """Collect storage classes and which of them are default.""" + storage_classes = self._get_resource(["storageclass"]) + if not storage_classes: + return {} + + classes = [] + default_classes = [] + for storage_class in storage_classes.get("items", []): + metadata = storage_class.get("metadata", {}) + name = metadata.get("name") + annotations = metadata.get("annotations") or {} + is_default = annotations.get("storageclass.kubernetes.io/is-default-class") == "true" + classes.append( + { + "name": name, + "provisioner": storage_class.get("provisioner"), + "default": is_default, + } + ) + if is_default: + default_classes.append(name) + + return {"storage_classes": classes, "default_storage_classes": default_classes} + + def _collect_identity_providers(self) -> list: + """Collect configured identity provider names and types (no credentials).""" + oauth = self._get_resource(["oauth", "cluster"]) + if not oauth: + return [] + + providers = oauth.get("spec", {}).get("identityProviders") or [] + return [{"name": provider.get("name"), "type": provider.get("type")} for provider in providers] + + def _collect_operators(self) -> list: + """Collect installed operator subscriptions (name, namespace, channel, CSV).""" + subscriptions = self._get_resource(["subscriptions.operators.coreos.com", "--all-namespaces"]) + if not subscriptions: + return [] + + operators = [] + for item in subscriptions.get("items", []): + spec = item.get("spec", {}) + metadata = item.get("metadata", {}) + operators.append( + { + "name": spec.get("name") or metadata.get("name"), + "namespace": metadata.get("namespace"), + "channel": spec.get("channel"), + "installed_csv": item.get("status", {}).get("installedCSV"), + } + ) + + return sorted(operators, key=lambda operator: (operator["namespace"] or "", operator["name"] or "")) + + @staticmethod + def _build_summary(overview: dict) -> str: + """Build a one-line human-readable summary of the overview.""" + identity = overview["cluster_identity"] + topology = overview["topology"] + network = overview["network"] + + nodes_by_role = topology.get("nodes_by_role") or {} + roles_summary = ", ".join(f"{count}x {role}" for role, count in sorted(nodes_by_role.items())) + return ( + f"OpenShift {identity.get('version') or 'unknown'} " + f"on {identity.get('platform') or 'unknown platform'} | " + f"{topology.get('node_count', 0)} nodes ({roles_summary or 'roles unknown'}) | " + f"CNI: {network.get('network_type') or 'unknown'} | " + f"operators: {len(overview['operators'])}" + ) diff --git a/tests/domains/test_cluster_overview_domain.py b/tests/domains/test_cluster_overview_domain.py new file mode 100644 index 0000000..e67bf5c --- /dev/null +++ b/tests/domains/test_cluster_overview_domain.py @@ -0,0 +1,19 @@ +"""Tests for cluster overview domain.""" + +from in_cluster_checks.domains.cluster_overview_domain import ClusterOverviewDomain +from in_cluster_checks.rules.cluster_overview.architecture_overview import ClusterArchitectureOverview + + +def test_cluster_overview_domain_name(): + """Test domain name.""" + domain = ClusterOverviewDomain() + assert domain.domain_name() == "cluster_overview" + + +def test_cluster_overview_domain_rules(): + """Test domain returns correct rules.""" + domain = ClusterOverviewDomain() + rules = domain.get_rule_classes() + + assert len(rules) == 1 + assert ClusterArchitectureOverview in rules diff --git a/tests/rules/cluster_overview/__init__.py b/tests/rules/cluster_overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rules/cluster_overview/test_architecture_overview.py b/tests/rules/cluster_overview/test_architecture_overview.py new file mode 100644 index 0000000..6c14fa2 --- /dev/null +++ b/tests/rules/cluster_overview/test_architecture_overview.py @@ -0,0 +1,252 @@ +"""Tests for ClusterArchitectureOverview rule.""" + +import json + +import pytest + +from in_cluster_checks.rules.cluster_overview.architecture_overview import ClusterArchitectureOverview +from tests.pytest_tools.test_operator_base import CmdOutput +from tests.pytest_tools.test_rule_base import RuleScenarioParams, RuleTestBase + +CLUSTER_VERSION_JSON = json.dumps( + { + "spec": {"channel": "stable-4.16", "clusterID": "11111111-2222-3333-4444-555555555555"}, + "status": { + "desired": {"version": "4.16.21"}, + "history": [{"version": "4.16.21"}, {"version": "4.16.20"}], + }, + } +) + +INFRASTRUCTURE_JSON = json.dumps( + { + "status": { + "platformStatus": {"type": "BareMetal"}, + "infrastructureName": "prod-x7k2p", + "apiServerURL": "https://api.prod.example.com:6443", + "controlPlaneTopology": "HighlyAvailable", + "infrastructureTopology": "HighlyAvailable", + } + } +) + +DNS_CONFIG_JSON = json.dumps({"spec": {"baseDomain": "prod.example.com"}}) + +NODES_JSON = json.dumps( + { + "items": [ + { + "metadata": { + "labels": { + "node-role.kubernetes.io/control-plane": "", + "node-role.kubernetes.io/master": "", + } + }, + "status": { + "nodeInfo": { + "kubeletVersion": "v1.29.8", + "osImage": "Red Hat Enterprise Linux CoreOS 416.94", + } + }, + }, + { + "metadata": {"labels": {"node-role.kubernetes.io/worker": ""}}, + "status": { + "nodeInfo": { + "kubeletVersion": "v1.29.8", + "osImage": "Red Hat Enterprise Linux CoreOS 416.94", + } + }, + }, + ] + } +) + +NETWORK_CONFIG_JSON = json.dumps( + { + "status": { + "networkType": "OVNKubernetes", + "clusterNetwork": [{"cidr": "10.128.0.0/14"}], + "serviceNetwork": ["172.30.0.0/16"], + "clusterNetworkMTU": 1400, + } + } +) + +STORAGE_CLASSES_JSON = json.dumps( + { + "items": [ + { + "metadata": { + "name": "ocs-storagecluster-ceph-rbd", + "annotations": {"storageclass.kubernetes.io/is-default-class": "true"}, + }, + "provisioner": "openshift-storage.rbd.csi.ceph.com", + }, + { + "metadata": {"name": "ocs-storagecluster-cephfs"}, + "provisioner": "openshift-storage.cephfs.csi.ceph.com", + }, + ] + } +) + +OAUTH_JSON = json.dumps({"spec": {"identityProviders": [{"name": "corp-ldap", "type": "LDAP"}]}}) + +SUBSCRIPTIONS_JSON = json.dumps( + { + "items": [ + { + "metadata": {"name": "odf-operator", "namespace": "openshift-storage"}, + "spec": {"name": "odf-operator", "channel": "stable-4.16"}, + "status": {"installedCSV": "odf-operator.v4.16.3"}, + } + ] + } +) + +SUBSCRIPTIONS_KEY = ("get", ("subscriptions.operators.coreos.com", "--all-namespaces", "-o", "json")) + +FULL_CLUSTER_OC_OUTPUTS = { + ("get", ("infrastructure", "cluster", "-o", "json")): CmdOutput(INFRASTRUCTURE_JSON), + ("get", ("clusterversion", "version", "-o", "json")): CmdOutput(CLUSTER_VERSION_JSON), + ("get", ("dns.config", "cluster", "-o", "json")): CmdOutput(DNS_CONFIG_JSON), + ("get", ("nodes", "-o", "json")): CmdOutput(NODES_JSON), + ("get", ("network.config", "cluster", "-o", "json")): CmdOutput(NETWORK_CONFIG_JSON), + ("get", ("storageclass", "-o", "json")): CmdOutput(STORAGE_CLASSES_JSON), + ("get", ("oauth", "cluster", "-o", "json")): CmdOutput(OAUTH_JSON), + SUBSCRIPTIONS_KEY: CmdOutput(SUBSCRIPTIONS_JSON), +} + +RBAC_DENIED = CmdOutput("", return_code=1, err="Error from server (Forbidden)") + +PARTIAL_CLUSTER_OC_OUTPUTS = { + ("get", ("infrastructure", "cluster", "-o", "json")): RBAC_DENIED, + ("get", ("clusterversion", "version", "-o", "json")): CmdOutput(CLUSTER_VERSION_JSON), + ("get", ("dns.config", "cluster", "-o", "json")): RBAC_DENIED, + ("get", ("nodes", "-o", "json")): RBAC_DENIED, + ("get", ("network.config", "cluster", "-o", "json")): RBAC_DENIED, + ("get", ("storageclass", "-o", "json")): RBAC_DENIED, + ("get", ("oauth", "cluster", "-o", "json")): RBAC_DENIED, + SUBSCRIPTIONS_KEY: RBAC_DENIED, +} + +# Resources readable but empty/minimal: no IdPs, no storage classes, no +# subscriptions, a node without role labels — distinct from RBAC denial. +EMPTY_CLUSTER_OC_OUTPUTS = { + ("get", ("infrastructure", "cluster", "-o", "json")): CmdOutput(INFRASTRUCTURE_JSON), + ("get", ("clusterversion", "version", "-o", "json")): CmdOutput(CLUSTER_VERSION_JSON), + ("get", ("dns.config", "cluster", "-o", "json")): CmdOutput(json.dumps({})), + ("get", ("nodes", "-o", "json")): CmdOutput(json.dumps({"items": [{"metadata": {"labels": {}}, "status": {}}]})), + ("get", ("network.config", "cluster", "-o", "json")): CmdOutput(json.dumps({"status": {}})), + ("get", ("storageclass", "-o", "json")): CmdOutput(json.dumps({"items": []})), + ("get", ("oauth", "cluster", "-o", "json")): CmdOutput(json.dumps({"spec": {}})), + SUBSCRIPTIONS_KEY: CmdOutput(json.dumps({"items": []})), +} + + +class TestClusterArchitectureOverview(RuleTestBase): + """Test ClusterArchitectureOverview rule.""" + + tested_type = ClusterArchitectureOverview + + scenario_info = [ + RuleScenarioParams( + "full overview collected on a healthy cluster", + oc_cmd_output_dict=FULL_CLUSTER_OC_OUTPUTS, + info_msg=( + "OpenShift 4.16.21 on BareMetal | " + "2 nodes (1x control-plane, 1x master, 1x worker) | " + "CNI: OVNKubernetes | operators: 1" + ), + ), + RuleScenarioParams( + "partial overview when only ClusterVersion is readable", + oc_cmd_output_dict=PARTIAL_CLUSTER_OC_OUTPUTS, + info_msg=( + "OpenShift 4.16.21 on unknown platform | " "0 nodes (roles unknown) | " "CNI: unknown | operators: 0" + ), + ), + RuleScenarioParams( + "overview on a minimal cluster with readable but empty resources", + oc_cmd_output_dict=EMPTY_CLUSTER_OC_OUTPUTS, + info_msg=("OpenShift 4.16.21 on BareMetal | " "1 nodes (1x unknown) | " "CNI: unknown | operators: 0"), + ), + ] + + scenario_unexpected_system_output = [ + RuleScenarioParams( + "rule is skipped when ClusterVersion cannot be read", + oc_cmd_output_dict={ + ("get", ("infrastructure", "cluster", "-o", "json")): RBAC_DENIED, + ("get", ("clusterversion", "version", "-o", "json")): RBAC_DENIED, + }, + ), + ] + + @pytest.mark.parametrize("scenario_params", scenario_info) + def test_scenario_info(self, scenario_params, tested_object): + RuleTestBase.test_scenario_info(self, scenario_params, tested_object) + + @pytest.mark.parametrize("scenario_params", scenario_unexpected_system_output) + def test_scenario_unexpected_system_output(self, scenario_params, tested_object): + RuleTestBase.test_scenario_unexpected_system_output(self, scenario_params, tested_object) + + @pytest.mark.parametrize("scenario_params", scenario_info[:1]) + def test_system_info_structure(self, scenario_params, tested_object): + """Verify the structured overview data returned in system_info.""" + self._init_validation_object(tested_object, scenario_params) + + with self._apply_patches(scenario_params, tested_object): + result = tested_object.run_rule() + + overview = result.system_info + identity = overview["cluster_identity"] + assert identity["version"] == "4.16.21" + assert identity["channel"] == "stable-4.16" + assert identity["platform"] == "BareMetal" + assert identity["base_domain"] == "prod.example.com" + assert identity["version_history"] == ["4.16.21", "4.16.20"] + + topology = overview["topology"] + assert topology["control_plane_topology"] == "HighlyAvailable" + assert topology["node_count"] == 2 + assert topology["nodes_by_role"] == {"control-plane": 1, "master": 1, "worker": 1} + assert topology["kubelet_versions"] == ["v1.29.8"] + + network = overview["network"] + assert network["network_type"] == "OVNKubernetes" + assert network["cluster_network"] == ["10.128.0.0/14"] + assert network["service_network"] == ["172.30.0.0/16"] + assert network["cluster_network_mtu"] == 1400 + + storage = overview["storage"] + assert storage["default_storage_classes"] == ["ocs-storagecluster-ceph-rbd"] + assert len(storage["storage_classes"]) == 2 + + assert overview["identity_providers"] == [{"name": "corp-ldap", "type": "LDAP"}] + + assert overview["operators"] == [ + { + "name": "odf-operator", + "namespace": "openshift-storage", + "channel": "stable-4.16", + "installed_csv": "odf-operator.v4.16.3", + } + ] + + @pytest.mark.parametrize("scenario_params", scenario_info[2:]) + def test_system_info_structure_empty_cluster(self, scenario_params, tested_object): + """Verify empty-but-readable resources yield empty sections, not failures.""" + self._init_validation_object(tested_object, scenario_params) + + with self._apply_patches(scenario_params, tested_object): + result = tested_object.run_rule() + + overview = result.system_info + assert overview["cluster_identity"].get("base_domain") is None + assert overview["topology"]["nodes_by_role"] == {"unknown": 1} + assert overview["network"]["network_type"] is None + assert overview["storage"] == {"storage_classes": [], "default_storage_classes": []} + assert overview["identity_providers"] == [] + assert overview["operators"] == []