diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b801f2b6..97872d7d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -45,6 +45,18 @@ "source": "./rh-virt", "category": "virtualization", "skills": "./skills" + }, + { + "name": "ocp-admin", + "description": "Red Hat OpenShift Administration Agentic Skills Collection", + "version": "1.0.0", + "author": { + "name": "Red Hat Ecosystem Engineering", + "email": "eco-engineering@redhat.com" + }, + "source": "./ocp-admin", + "category": "openshift", + "skills": "./skills" } ] } \ No newline at end of file diff --git a/README.md b/README.md index add2d02a..0ed1b81b 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,12 @@ Add the server configuration to `/.mcp.json`: - ❌ Never hardcode API keys, tokens, or secrets - ✅ Set appropriate security isolation level +**Platform Notes (Linux vs macOS):** + +On **Linux**, you may want to add `--userns=keep-id:uid=65532,gid=65532` to the Podman `args` for proper UID/GID mapping inside the container. This ensures the container process runs with the correct non-root user identity. + +On **macOS**, this flag is **not supported** because Podman runs inside a Linux VM where user namespace mapping behaves differently. Omit it on macOS or the container will fail to start. + ### Step 2: Add Custom Metadata (Optional) To display repository links and tool descriptions on the documentation site, add an entry to `docs/mcp.json`: diff --git a/ocp-admin/.mcp.json b/ocp-admin/.mcp.json index 894e7526..1ccf863f 100644 --- a/ocp-admin/.mcp.json +++ b/ocp-admin/.mcp.json @@ -7,7 +7,6 @@ "--rm", "-i", "--network=host", - "--userns=keep-id:uid=65532,gid=65532", "-v", "${KUBECONFIG}:/kubeconfig:ro,Z", "--entrypoint", "/app/kubernetes-mcp-server", "quay.io/ecosystem-appeng/openshift-mcp-server:latest", diff --git a/ocp-admin/README.md b/ocp-admin/README.md index 3b0dd20a..63b564ee 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -16,6 +16,17 @@ Administration and management tools for OpenShift Container Platform. This pack - OpenShift cluster access via `KUBECONFIG` - For multi-cluster reports, a kubeconfig with multiple contexts +## Helper Scripts + +The `cluster-report` skill uses two Python scripts (stdlib only, no dependencies) in `scripts/cluster-report/`: + +| Script | Purpose | +|--------|---------| +| `assemble.py` | Resolves `$file` references in the manifest JSON, loading persisted MCP output from disk into a complete data structure. With `--aggregate`, pipes into `aggregate.py` automatically. | +| `aggregate.py` | Computes per-cluster and fleet-wide metrics (CPU/memory usage, pod status counts, GPU totals, top namespaces) and flags attention items (>85% utilization, failed pods, missing metrics). | + +Both scripts read from stdin and write to stdout. They are invoked as a pipeline by the skill and should be treated as black boxes. + ## MCP Servers - **openshift** - OpenShift cluster management with multi-cluster support diff --git a/ocp-admin/scripts/cluster-report/aggregate.py b/ocp-admin/scripts/cluster-report/aggregate.py new file mode 100644 index 00000000..031f0bbe --- /dev/null +++ b/ocp-admin/scripts/cluster-report/aggregate.py @@ -0,0 +1,601 @@ +#!/usr/bin/env python3 + +import json +import math +import re +import sys + + +def parse_cpu(value): + if value is None: + return 0.0 + s = str(value).strip() + if s.endswith("m"): + return float(s[:-1]) / 1000.0 + if s.endswith("n"): + return float(s[:-1]) / 1e9 + if s.endswith("u"): + return float(s[:-1]) / 1e6 + return float(s) + + +def parse_memory(value): + if value is None: + return 0.0 + s = str(value).strip() + multipliers = { + "Ki": 1024, + "Mi": 1024 ** 2, + "Gi": 1024 ** 3, + "Ti": 1024 ** 4, + "K": 1000, + "M": 1000 ** 2, + "G": 1000 ** 3, + "T": 1000 ** 4, + } + for suffix, mult in sorted(multipliers.items(), key=lambda x: -len(x[0])): + if s.endswith(suffix): + num = float(s[: -len(suffix)]) + return (num * mult) / (1024 ** 3) + return float(s) / (1024 ** 3) + + +def detect_node_role(labels): + if not labels: + return "worker" + prefix = "node-role.kubernetes.io/" + roles = [] + for key in labels: + if key.startswith(prefix): + role = key[len(prefix):] + if role: + roles.append(role) + if not roles: + return "worker" + priority = ["control-plane", "master", "infra", "worker"] + for p in priority: + if p in roles: + return p + return roles[0] + + +GPU_KEYS = ["nvidia.com/gpu", "amd.com/gpu", "intel.com/gpu"] + + +def detect_gpus(allocatable): + if not allocatable: + return 0, "" + for key in GPU_KEYS: + val = allocatable.get(key) + if val is not None: + count = int(val) + if count > 0: + return count, key + return 0, "" + + +def parse_tabular(text): + if not text or not isinstance(text, str): + return [] + + lines = text.splitlines() + non_blank = [line for line in lines if line.strip()] + if len(non_blank) < 2: + return [] + + header_line = non_blank[0] + data_lines = non_blank[1:] + + starts = [0] + i = 0 + while i < len(header_line): + if header_line[i] == " ": + space_start = i + while i < len(header_line) and header_line[i] == " ": + i += 1 + if i < len(header_line) and (i - space_start) >= 2: + starts.append(i) + else: + i += 1 + + headers = [] + for idx, start in enumerate(starts): + end = starts[idx + 1] if idx + 1 < len(starts) else len(header_line) + headers.append(header_line[start:end].strip()) + + result = [] + for line in data_lines: + row = {} + for idx, start in enumerate(starts): + end = starts[idx + 1] if idx + 1 < len(starts) else len(line) + value = line[start:end].strip() if start < len(line) else "" + row[headers[idx]] = value + result.append(row) + + return result + + +def parse_labels_string(labels_str): + if not labels_str or labels_str == "": + return {} + result = {} + for item in labels_str.split(","): + item = item.strip() + if not item: + continue + if "=" in item: + key, val = item.split("=", 1) + result[key] = val + else: + result[item] = "" + return result + + +def _col(row, name, default=""): + if name in row: + return row[name] + name_lower = name.lower() + for key in row: + if key.lower() == name_lower: + return row[key] + return default + + +def parse_pods_tabular(text): + rows = parse_tabular(text) + result = [] + for row in rows: + result.append({ + "namespace": _col(row, "NAMESPACE", "unknown"), + "name": _col(row, "NAME", "unknown"), + "status": _col(row, "STATUS", "Unknown"), + }) + return result + + +def parse_nodes_list_tabular(text): + rows = parse_tabular(text) + result = [] + for row in rows: + name = _col(row, "NAME", "unknown") + roles_str = _col(row, "ROLES", "") + labels_str = _col(row, "LABELS", "") + + labels = parse_labels_string(labels_str) + if roles_str and roles_str != "": + for role in roles_str.split(","): + role = role.strip() + if role: + label_key = f"node-role.kubernetes.io/{role}" + if label_key not in labels: + labels[label_key] = "" + + result.append({ + "metadata": {"name": name, "labels": labels}, + "status": {}, + }) + return result + + +def parse_nodes_top_tabular(text): + rows = parse_tabular(text) + result = [] + for row in rows: + result.append({ + "name": _col(row, "NAME", "unknown"), + "cpu_usage": _col(row, "CPU(cores)") or None, + "memory_usage": _col(row, "MEMORY(bytes)") or None, + }) + return result + + +def parse_projects_tabular(text): + rows = parse_tabular(text) + return [{"name": _col(row, "NAME", "unknown")} for row in rows] + + +def parse_namespaces_tabular(text): + rows = parse_tabular(text) + return [{"name": _col(row, "NAME", "unknown")} for row in rows] + + +def classify_pod_status(pod): + if isinstance(pod, dict) and "status" in pod and isinstance(pod["status"], str): + return pod["status"] + + status_obj = pod.get("status", {}) + if isinstance(status_obj, str): + return status_obj + + phase = status_obj.get("phase", "Unknown") + + container_statuses = status_obj.get("containerStatuses", []) + if not container_statuses: + container_statuses = status_obj.get("initContainerStatuses", []) + + for cs in container_statuses or []: + state = cs.get("state", {}) + waiting = state.get("waiting", {}) + reason = waiting.get("reason", "") + if reason in ( + "CrashLoopBackOff", + "ImagePullBackOff", + "ErrImagePull", + "CreateContainerError", + "CreateContainerConfigError", + "RunContainerError", + ): + return reason + + if phase == "Completed": + return "Succeeded" + + return phase + + +def aggregate_pods_by_namespace(pods, top_n=10): + if not pods: + return [] + + ns_data = {} + for pod in pods: + if "metadata" in pod: + ns = pod["metadata"].get("namespace", "unknown") + else: + ns = pod.get("namespace", "unknown") + + status = classify_pod_status(pod) + + if ns not in ns_data: + ns_data[ns] = {"namespace": ns, "pods_total": 0, "running": 0, + "pending": 0, "failed": 0, "succeeded": 0, "other": 0} + + ns_data[ns]["pods_total"] += 1 + if status == "Running": + ns_data[ns]["running"] += 1 + elif status == "Pending": + ns_data[ns]["pending"] += 1 + elif status in ("Failed", "Error"): + ns_data[ns]["failed"] += 1 + elif status in ("Succeeded", "Completed"): + ns_data[ns]["succeeded"] += 1 + else: + ns_data[ns]["other"] += 1 + + sorted_ns = sorted(ns_data.values(), key=lambda x: x["pods_total"], reverse=True) + return sorted_ns[:top_n] + + +def process_nodes(nodes_top, nodes_list): + nodes = {} + metrics_available = nodes_top is not None + + if nodes_list: + for node in nodes_list: + if isinstance(node, dict): + meta = node.get("metadata", {}) + name = meta.get("name", node.get("name", "unknown")) + labels = meta.get("labels", node.get("labels", {})) + status = node.get("status", {}) + allocatable = status.get("allocatable", {}) + capacity = status.get("capacity", {}) + + role = detect_node_role(labels) + gpu_count, gpu_type = detect_gpus(allocatable) + + cpu_total = parse_cpu(allocatable.get("cpu") or capacity.get("cpu")) + mem_total = parse_memory(allocatable.get("memory") or capacity.get("memory")) + + nodes[name] = { + "name": name, + "role": role, + "cpu_used": None, + "cpu_total": round(cpu_total, 2), + "memory_used": None, + "memory_total": round(mem_total, 2), + "gpus": gpu_count, + "gpu_type": gpu_type, + } + + if nodes_top: + for entry in nodes_top: + if isinstance(entry, dict): + name = entry.get("name", entry.get("NAME", "unknown")) + cpu_used = entry.get("cpu_usage") or entry.get("CPU(cores)") or entry.get("cpu") + mem_used = entry.get("memory_usage") or entry.get("MEMORY(bytes)") or entry.get("memory") + + if name in nodes: + if cpu_used is not None: + nodes[name]["cpu_used"] = round(parse_cpu(str(cpu_used)), 2) + if mem_used is not None: + nodes[name]["memory_used"] = round(parse_memory(str(mem_used)), 2) + else: + nodes[name] = { + "name": name, + "role": "worker", + "cpu_used": round(parse_cpu(str(cpu_used)), 2) if cpu_used else None, + "cpu_total": None, + "memory_used": round(parse_memory(str(mem_used)), 2) if mem_used else None, + "memory_total": None, + "gpus": 0, + "gpu_type": "", + } + + return list(nodes.values()), metrics_available + + +def process_cluster(cluster_data): + errors = cluster_data.get("errors", []) + nodes_top = cluster_data.get("nodes_top") + nodes_list = cluster_data.get("nodes_list") + projects = cluster_data.get("projects") + namespaces = cluster_data.get("namespaces") + pods = cluster_data.get("pods") + + if isinstance(pods, str): + pods = parse_pods_tabular(pods) + if isinstance(nodes_top, str): + nodes_top = parse_nodes_top_tabular(nodes_top) + if isinstance(nodes_list, str): + nodes_list = parse_nodes_list_tabular(nodes_list) + if isinstance(projects, str): + projects = parse_projects_tabular(projects) + if isinstance(namespaces, str): + namespaces = parse_namespaces_tabular(namespaces) + + nodes_detail, metrics_available = process_nodes(nodes_top, nodes_list) + + cpu_used = None + cpu_total = 0.0 + mem_used = None + mem_total = 0.0 + gpu_total = 0 + + for node in nodes_detail: + if node["cpu_total"] is not None: + cpu_total += node["cpu_total"] + if node["memory_total"] is not None: + mem_total += node["memory_total"] + if node["cpu_used"] is not None: + cpu_used = (cpu_used or 0.0) + node["cpu_used"] + if node["memory_used"] is not None: + mem_used = (mem_used or 0.0) + node["memory_used"] + gpu_total += node["gpus"] + + cpu_percent = None + if cpu_used is not None and cpu_total > 0: + cpu_percent = round((cpu_used / cpu_total) * 100) + + mem_percent = None + if mem_used is not None and mem_total > 0: + mem_percent = round((mem_used / mem_total) * 100) + + project_count = 0 + if projects is not None: + project_count = len(projects) if isinstance(projects, list) else 0 + elif namespaces is not None: + project_count = len(namespaces) if isinstance(namespaces, list) else 0 + + pod_status = { + "Running": 0, + "Pending": 0, + "Succeeded": 0, + "Failed": 0, + "Unknown": 0, + "CrashLoopBackOff": 0, + "ImagePullBackOff": 0, + "ErrImagePull": 0, + "Other": 0, + } + pods_running = 0 + pods_total = 0 + + if pods and isinstance(pods, list): + pods_total = len(pods) + for pod in pods: + status = classify_pod_status(pod) + if status in pod_status: + pod_status[status] += 1 + else: + pod_status["Other"] += 1 + if status == "Running": + pods_running += 1 + + top_namespaces = aggregate_pods_by_namespace(pods or []) + + return { + "overview": { + "cluster": cluster_data.get("context", "unknown"), + "server": cluster_data.get("server", "unknown"), + "node_count": len(nodes_detail), + "cpu_used_cores": round(cpu_used, 1) if cpu_used is not None else None, + "cpu_total_cores": round(cpu_total, 1), + "cpu_percent": cpu_percent, + "memory_used_gib": round(mem_used, 1) if mem_used is not None else None, + "memory_total_gib": round(mem_total, 1), + "memory_percent": mem_percent, + "gpu_total": gpu_total, + "project_count": project_count, + "pods_running": pods_running, + "pods_total": pods_total, + "metrics_available": metrics_available, + }, + "nodes": nodes_detail, + "pod_status": {k: v for k, v in pod_status.items() if v > 0}, + "top_namespaces": top_namespaces, + "errors": errors, + } + + +def compute_totals(overview_list): + totals = { + "node_count": 0, + "cpu_used_cores": None, + "cpu_total_cores": 0.0, + "memory_used_gib": None, + "memory_total_gib": 0.0, + "gpu_total": 0, + "project_count": 0, + "pods_running": 0, + "pods_total": 0, + } + + for ov in overview_list: + totals["node_count"] += ov.get("node_count", 0) + totals["cpu_total_cores"] += ov.get("cpu_total_cores", 0) + totals["memory_total_gib"] += ov.get("memory_total_gib", 0) + totals["gpu_total"] += ov.get("gpu_total", 0) + totals["project_count"] += ov.get("project_count", 0) + totals["pods_running"] += ov.get("pods_running", 0) + totals["pods_total"] += ov.get("pods_total", 0) + + if ov.get("cpu_used_cores") is not None: + totals["cpu_used_cores"] = (totals["cpu_used_cores"] or 0) + ov["cpu_used_cores"] + if ov.get("memory_used_gib") is not None: + totals["memory_used_gib"] = (totals["memory_used_gib"] or 0) + ov["memory_used_gib"] + + totals["cpu_total_cores"] = round(totals["cpu_total_cores"], 1) + totals["memory_total_gib"] = round(totals["memory_total_gib"], 1) + + if totals["cpu_used_cores"] is not None: + totals["cpu_used_cores"] = round(totals["cpu_used_cores"], 1) + if totals["memory_used_gib"] is not None: + totals["memory_used_gib"] = round(totals["memory_used_gib"], 1) + + if totals["cpu_used_cores"] is not None and totals["cpu_total_cores"] > 0: + totals["cpu_percent"] = round((totals["cpu_used_cores"] / totals["cpu_total_cores"]) * 100) + else: + totals["cpu_percent"] = None + + if totals["memory_used_gib"] is not None and totals["memory_total_gib"] > 0: + totals["memory_percent"] = round((totals["memory_used_gib"] / totals["memory_total_gib"]) * 100) + else: + totals["memory_percent"] = None + + return totals + + +def detect_attention_items(overview_list, per_cluster): + items = [] + + for ov in overview_list: + cluster = ov["cluster"] + pc = per_cluster.get(cluster, {}) + + if ov.get("cpu_percent") is not None and ov["cpu_percent"] > 85: + items.append(f"{cluster}: Cluster CPU usage at {ov['cpu_percent']}% (>85% threshold)") + + if ov.get("memory_percent") is not None and ov["memory_percent"] > 85: + items.append(f"{cluster}: Cluster memory usage at {ov['memory_percent']}% (>85% threshold)") + + for node in pc.get("nodes", []): + if (node.get("cpu_used") is not None and node.get("cpu_total") + and node["cpu_total"] > 0): + node_cpu_pct = (node["cpu_used"] / node["cpu_total"]) * 100 + if node_cpu_pct > 85: + items.append( + f"{cluster}: Node {node['name']} CPU at {round(node_cpu_pct)}% (>85%)" + ) + if (node.get("memory_used") is not None and node.get("memory_total") + and node["memory_total"] > 0): + node_mem_pct = (node["memory_used"] / node["memory_total"]) * 100 + if node_mem_pct > 85: + items.append( + f"{cluster}: Node {node['name']} memory at {round(node_mem_pct)}% (>85%)" + ) + + pod_status = pc.get("pod_status", {}) + failed = pod_status.get("Failed", 0) + pod_status.get("Error", 0) + if failed > 0: + items.append(f"{cluster}: {failed} pods in Failed/Error state") + + unknown = pod_status.get("Unknown", 0) + if unknown > 0: + items.append(f"{cluster}: {unknown} pods in Unknown state") + + pending = pod_status.get("Pending", 0) + if pending > 0: + items.append(f"{cluster}: {pending} pods in Pending state (possible resource constraints)") + + crash = pod_status.get("CrashLoopBackOff", 0) + if crash > 0: + items.append(f"{cluster}: {crash} pods in CrashLoopBackOff") + + img_pull = pod_status.get("ImagePullBackOff", 0) + pod_status.get("ErrImagePull", 0) + if img_pull > 0: + items.append(f"{cluster}: {img_pull} pods with image pull errors") + + if not ov.get("metrics_available", True): + items.append(f"{cluster}: Metrics Server not available — no CPU/memory usage data") + + for err in pc.get("errors", []): + items.append(f"{cluster}: {err}") + + return items + + +def main(): + try: + raw = sys.stdin.read() + except Exception as e: + json.dump({"error": f"Failed to read stdin: {e}"}, sys.stdout, indent=2) + sys.exit(1) + + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + json.dump({"error": f"Invalid JSON input: {e}"}, sys.stdout, indent=2) + sys.exit(1) + + clusters_input = data.get("clusters", {}) + if not clusters_input: + json.dump({"error": "No clusters found in input"}, sys.stdout, indent=2) + sys.exit(1) + + overview_list = [] + per_cluster = {} + failed_clusters = [] + + for ctx_name, cluster_data in clusters_input.items(): + cluster_data.setdefault("context", ctx_name) + result = process_cluster(cluster_data) + overview_list.append(result["overview"]) + per_cluster[ctx_name] = { + "nodes": result["nodes"], + "pod_status": result["pod_status"], + "top_namespaces": result["top_namespaces"], + "errors": result["errors"], + } + if result["errors"]: + for err in result["errors"]: + failed_clusters.append({ + "context": ctx_name, + "server": cluster_data.get("server", "unknown"), + "error": err, + }) + + clusters_reported = sum( + 1 for ov in overview_list + if ov["node_count"] > 0 or ov["pods_total"] > 0 or ov["project_count"] > 0 + ) + clusters_failed = len(overview_list) - clusters_reported + + totals = compute_totals(overview_list) + attention = detect_attention_items(overview_list, per_cluster) + + output = { + "generated_at": data.get("generated_at", ""), + "clusters_reported": clusters_reported, + "clusters_failed": clusters_failed, + "overview": overview_list, + "totals": totals, + "per_cluster": per_cluster, + "attention": attention, + "failed_clusters": failed_clusters, + } + + json.dump(output, sys.stdout, indent=2) + + +if __name__ == "__main__": + main() diff --git a/ocp-admin/scripts/cluster-report/assemble.py b/ocp-admin/scripts/cluster-report/assemble.py new file mode 100644 index 00000000..ee0f9f67 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/assemble.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys + +DATA_FIELDS = ("nodes_top", "nodes_list", "projects", "namespaces", "pods") + + +def unwrap_persisted_output(raw_content): + try: + data = json.loads(raw_content) + except (json.JSONDecodeError, ValueError): + return raw_content + + if isinstance(data, list) and len(data) > 0: + if all(isinstance(item, dict) and "type" in item for item in data): + texts = [] + for item in data: + if item.get("type") == "text" and "text" in item: + texts.append(item["text"]) + if texts: + return "\n".join(texts) + return None + + return data + + +def resolve_file_ref(file_path): + if not os.path.exists(file_path): + return None, f"File not found: {file_path}" + + try: + with open(file_path, "r") as f: + raw = f.read() + except PermissionError: + return None, f"Permission denied reading: {file_path}" + except OSError as e: + return None, f"Error reading {file_path}: {e}" + + if not raw.strip(): + return None, f"Empty file: {file_path}" + + content = unwrap_persisted_output(raw) + + if content is None: + return None, f"No text content in envelope: {file_path}" + + return content, None + + +def resolve_cluster(cluster_data): + errors = list(cluster_data.get("errors", [])) + + for field in DATA_FIELDS: + value = cluster_data.get(field) + if isinstance(value, dict) and "$file" in value: + file_path = value["$file"] + content, error = resolve_file_ref(file_path) + if error: + cluster_data[field] = None + errors.append(error) + else: + cluster_data[field] = content + + cluster_data["errors"] = errors + return cluster_data + + +def main(): + aggregate_mode = "--aggregate" in sys.argv + + try: + raw = sys.stdin.read() + except Exception as e: + json.dump({"error": f"Failed to read stdin: {e}"}, sys.stdout, indent=2) + sys.exit(1) + + try: + manifest = json.loads(raw) + except json.JSONDecodeError as e: + json.dump({"error": f"Invalid manifest JSON: {e}"}, sys.stdout, indent=2) + sys.exit(1) + + clusters = manifest.get("clusters", {}) + for cluster_data in clusters.values(): + resolve_cluster(cluster_data) + + resolved_json = json.dumps(manifest, indent=2) + + if aggregate_mode: + script_dir = os.path.dirname(os.path.abspath(__file__)) + aggregate_script = os.path.join(script_dir, "aggregate.py") + proc = subprocess.run( + [sys.executable, aggregate_script], + input=resolved_json, + capture_output=True, + text=True, + ) + sys.stdout.write(proc.stdout) + if proc.stderr: + sys.stderr.write(proc.stderr) + sys.exit(proc.returncode) + else: + sys.stdout.write(resolved_json) + + +if __name__ == "__main__": + main() diff --git a/ocp-admin/scripts/cluster-report/test_aggregate.py b/ocp-admin/scripts/cluster-report/test_aggregate.py new file mode 100644 index 00000000..db3fa535 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/test_aggregate.py @@ -0,0 +1,863 @@ +#!/usr/bin/env python3 + +import json +import subprocess +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import aggregate + + +class TestParseCpu(unittest.TestCase): + def test_whole_cores(self): + self.assertEqual(aggregate.parse_cpu("4"), 4.0) + + def test_millicores(self): + self.assertEqual(aggregate.parse_cpu("500m"), 0.5) + + def test_millicores_whole(self): + self.assertEqual(aggregate.parse_cpu("4000m"), 4.0) + + def test_nanocores(self): + self.assertAlmostEqual(aggregate.parse_cpu("1000000000n"), 1.0) + + def test_microcores(self): + self.assertAlmostEqual(aggregate.parse_cpu("1000000u"), 1.0) + + def test_none(self): + self.assertEqual(aggregate.parse_cpu(None), 0.0) + + def test_integer(self): + self.assertEqual(aggregate.parse_cpu(8), 8.0) + + def test_fractional(self): + self.assertEqual(aggregate.parse_cpu("0.5"), 0.5) + + +class TestParseMemory(unittest.TestCase): + def test_gibibytes(self): + self.assertEqual(aggregate.parse_memory("16Gi"), 16.0) + + def test_mebibytes(self): + self.assertEqual(aggregate.parse_memory("16384Mi"), 16.0) + + def test_kibibytes(self): + self.assertAlmostEqual(aggregate.parse_memory("16777216Ki"), 16.0) + + def test_raw_bytes(self): + self.assertAlmostEqual(aggregate.parse_memory("17179869184"), 16.0, places=0) + + def test_tebibytes(self): + self.assertEqual(aggregate.parse_memory("1Ti"), 1024.0) + + def test_decimal_gigabytes(self): + val = aggregate.parse_memory("16G") + self.assertAlmostEqual(val, 16000000000 / (1024 ** 3), places=1) + + def test_none(self): + self.assertEqual(aggregate.parse_memory(None), 0.0) + + +class TestDetectNodeRole(unittest.TestCase): + def test_worker(self): + labels = {"node-role.kubernetes.io/worker": ""} + self.assertEqual(aggregate.detect_node_role(labels), "worker") + + def test_control_plane(self): + labels = {"node-role.kubernetes.io/control-plane": ""} + self.assertEqual(aggregate.detect_node_role(labels), "control-plane") + + def test_master(self): + labels = {"node-role.kubernetes.io/master": ""} + self.assertEqual(aggregate.detect_node_role(labels), "master") + + def test_infra(self): + labels = {"node-role.kubernetes.io/infra": ""} + self.assertEqual(aggregate.detect_node_role(labels), "infra") + + def test_multiple_roles_prefers_control_plane(self): + labels = { + "node-role.kubernetes.io/worker": "", + "node-role.kubernetes.io/control-plane": "", + } + self.assertEqual(aggregate.detect_node_role(labels), "control-plane") + + def test_no_role_labels(self): + labels = {"kubernetes.io/hostname": "node-1"} + self.assertEqual(aggregate.detect_node_role(labels), "worker") + + def test_empty_labels(self): + self.assertEqual(aggregate.detect_node_role({}), "worker") + + def test_none_labels(self): + self.assertEqual(aggregate.detect_node_role(None), "worker") + + +class TestDetectGpus(unittest.TestCase): + def test_nvidia_gpu(self): + alloc = {"cpu": "8", "memory": "32Gi", "nvidia.com/gpu": "2"} + count, gpu_type = aggregate.detect_gpus(alloc) + self.assertEqual(count, 2) + self.assertEqual(gpu_type, "nvidia.com/gpu") + + def test_amd_gpu(self): + alloc = {"amd.com/gpu": "4"} + count, gpu_type = aggregate.detect_gpus(alloc) + self.assertEqual(count, 4) + self.assertEqual(gpu_type, "amd.com/gpu") + + def test_intel_gpu(self): + alloc = {"intel.com/gpu": "1"} + count, gpu_type = aggregate.detect_gpus(alloc) + self.assertEqual(count, 1) + self.assertEqual(gpu_type, "intel.com/gpu") + + def test_no_gpus(self): + alloc = {"cpu": "8", "memory": "32Gi"} + count, gpu_type = aggregate.detect_gpus(alloc) + self.assertEqual(count, 0) + self.assertEqual(gpu_type, "") + + def test_zero_gpus(self): + alloc = {"nvidia.com/gpu": "0"} + count, gpu_type = aggregate.detect_gpus(alloc) + self.assertEqual(count, 0) + self.assertEqual(gpu_type, "") + + def test_none_allocatable(self): + count, gpu_type = aggregate.detect_gpus(None) + self.assertEqual(count, 0) + self.assertEqual(gpu_type, "") + + +class TestParseTabular(unittest.TestCase): + def test_basic_table(self): + text = "NAME STATUS\nnode-1 Ready\nnode-2 NotReady" + result = aggregate.parse_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["NAME"], "node-1") + self.assertEqual(result[0]["STATUS"], "Ready") + self.assertEqual(result[1]["NAME"], "node-2") + self.assertEqual(result[1]["STATUS"], "NotReady") + + def test_multiword_header(self): + text = "NAME DISPLAY NAME STATUS\nproj-1 My Project Active" + result = aggregate.parse_tabular(text) + self.assertEqual(len(result), 1) + self.assertIn("DISPLAY NAME", result[0]) + self.assertEqual(result[0]["DISPLAY NAME"], "My Project") + + def test_empty_input(self): + self.assertEqual(aggregate.parse_tabular(""), []) + self.assertEqual(aggregate.parse_tabular(None), []) + + def test_header_only(self): + self.assertEqual(aggregate.parse_tabular("NAME STATUS"), []) + + def test_short_data_line(self): + text = "NAME STATUS LABELS\nnode-1 Ready" + result = aggregate.parse_tabular(text) + self.assertEqual(result[0]["NAME"], "node-1") + self.assertEqual(result[0]["STATUS"], "Ready") + self.assertEqual(result[0]["LABELS"], "") + + def test_varying_column_widths(self): + text = "NAME STATUS AGE\nshort OK 1d\nvery-long Fail 30d" + result = aggregate.parse_tabular(text) + self.assertEqual(result[0]["NAME"], "short") + self.assertEqual(result[1]["NAME"], "very-long") + self.assertEqual(result[0]["STATUS"], "OK") + self.assertEqual(result[1]["STATUS"], "Fail") + + def test_blank_lines_skipped(self): + text = "NAME STATUS\n\nnode-1 Ready\n\n" + result = aggregate.parse_tabular(text) + self.assertEqual(len(result), 1) + + def test_real_mcp_pod_header(self): + text = ( + "NAMESPACE APIVERSION KIND NAME " + "READY STATUS RESTARTS AGE\n" + "openshift-dns v1 Pod dns-default-abc12 " + "1/1 Running 0 5d\n" + "aistor v1 Pod webhook-69496784f7 " + "0/1 ErrImagePull 0 4d" + ) + result = aggregate.parse_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["NAMESPACE"], "openshift-dns") + self.assertEqual(result[0]["STATUS"], "Running") + self.assertEqual(result[1]["STATUS"], "ErrImagePull") + + +class TestParseLabelsString(unittest.TestCase): + def test_basic_labels(self): + result = aggregate.parse_labels_string( + "node-role.kubernetes.io/worker=,kubernetes.io/hostname=node-1" + ) + self.assertEqual(result["node-role.kubernetes.io/worker"], "") + self.assertEqual(result["kubernetes.io/hostname"], "node-1") + + def test_empty_string(self): + self.assertEqual(aggregate.parse_labels_string(""), {}) + + def test_none(self): + self.assertEqual(aggregate.parse_labels_string(None), {}) + + def test_label_with_value(self): + result = aggregate.parse_labels_string("beta.kubernetes.io/arch=amd64") + self.assertEqual(result["beta.kubernetes.io/arch"], "amd64") + + def test_none_literal(self): + self.assertEqual(aggregate.parse_labels_string(""), {}) + + +class TestParsePodsTabular(unittest.TestCase): + def test_basic_pods(self): + text = ( + "NAMESPACE APIVERSION KIND NAME " + "READY STATUS RESTARTS AGE\n" + "openshift-mon v1 Pod prometheus-0 " + "1/1 Running 0 5d\n" + "default v1 Pod failing-pod " + "0/1 CrashLoopBackOff 15 1d" + ) + result = aggregate.parse_pods_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["namespace"], "openshift-mon") + self.assertEqual(result[0]["status"], "Running") + self.assertEqual(result[1]["namespace"], "default") + self.assertEqual(result[1]["status"], "CrashLoopBackOff") + + def test_empty_input(self): + self.assertEqual(aggregate.parse_pods_tabular(""), []) + + def test_various_statuses(self): + for status in ["Running", "Pending", "Failed", "Succeeded", + "ErrImagePull", "ImagePullBackOff", "Completed"]: + text = f"NAMESPACE NAME STATUS\ndefault pod-x {status}" + result = aggregate.parse_pods_tabular(text) + self.assertEqual(result[0]["status"], status, f"Failed for {status}") + + +class TestParseNodesListTabular(unittest.TestCase): + def test_basic_nodes(self): + text = ( + "APIVERSION KIND NAME STATUS ROLES AGE " + "VERSION LABELS\n" + "v1 Node worker-0 Ready worker 30d " + "v1.28 node-role.kubernetes.io/worker=,kubernetes.io/hostname=worker-0" + ) + result = aggregate.parse_nodes_list_tabular(text) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["metadata"]["name"], "worker-0") + self.assertIn("node-role.kubernetes.io/worker", + result[0]["metadata"]["labels"]) + + def test_role_from_roles_column(self): + text = ( + "APIVERSION KIND NAME STATUS ROLES AGE " + "VERSION LABELS\n" + "v1 Node master-0 Ready control-plane 30d " + "v1.28 kubernetes.io/hostname=master-0" + ) + result = aggregate.parse_nodes_list_tabular(text) + labels = result[0]["metadata"]["labels"] + role = aggregate.detect_node_role(labels) + self.assertEqual(role, "control-plane") + + def test_no_allocatable_data(self): + text = "APIVERSION KIND NAME STATUS ROLES LABELS\nv1 Node n1 Ready worker app=test" + result = aggregate.parse_nodes_list_tabular(text) + self.assertEqual(result[0]["status"], {}) + + +class TestParseNodesTopTabular(unittest.TestCase): + def test_basic_top(self): + text = ( + "NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)\n" + "node-1 4000m 50% 16Gi 50%\n" + "node-2 2000m 25% 8Gi 25%" + ) + result = aggregate.parse_nodes_top_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "node-1") + self.assertEqual(result[0]["cpu_usage"], "4000m") + self.assertEqual(result[0]["memory_usage"], "16Gi") + self.assertEqual(result[1]["name"], "node-2") + + def test_empty(self): + self.assertEqual(aggregate.parse_nodes_top_tabular(""), []) + + +class TestParseProjectsTabular(unittest.TestCase): + def test_basic(self): + text = ( + "APIVERSION KIND NAME DISPLAY NAME STATUS LABELS\n" + "project.openshift.io/v1 Project my-project My Project Active app=test\n" + "project.openshift.io/v1 Project default Active " + ) + result = aggregate.parse_projects_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "my-project") + self.assertEqual(result[1]["name"], "default") + + def test_empty(self): + self.assertEqual(aggregate.parse_projects_tabular(""), []) + + +class TestParseNamespacesTabular(unittest.TestCase): + def test_basic(self): + text = ( + "APIVERSION KIND NAME STATUS AGE LABELS\n" + "v1 Namespace kube-system Active 90d kubernetes.io/metadata.name=kube-system\n" + "v1 Namespace default Active 90d kubernetes.io/metadata.name=default" + ) + result = aggregate.parse_namespaces_tabular(text) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "kube-system") + self.assertEqual(result[1]["name"], "default") + + +class TestClassifyPodStatus(unittest.TestCase): + def test_running(self): + pod = {"status": {"phase": "Running", "containerStatuses": []}} + self.assertEqual(aggregate.classify_pod_status(pod), "Running") + + def test_pending(self): + pod = {"status": {"phase": "Pending"}} + self.assertEqual(aggregate.classify_pod_status(pod), "Pending") + + def test_succeeded(self): + pod = {"status": {"phase": "Succeeded"}} + self.assertEqual(aggregate.classify_pod_status(pod), "Succeeded") + + def test_failed(self): + pod = {"status": {"phase": "Failed"}} + self.assertEqual(aggregate.classify_pod_status(pod), "Failed") + + def test_completed_maps_to_succeeded(self): + pod = {"status": {"phase": "Completed"}} + self.assertEqual(aggregate.classify_pod_status(pod), "Succeeded") + + def test_crashloopbackoff_override(self): + pod = { + "status": { + "phase": "Running", + "containerStatuses": [ + {"state": {"waiting": {"reason": "CrashLoopBackOff"}}} + ], + } + } + self.assertEqual(aggregate.classify_pod_status(pod), "CrashLoopBackOff") + + def test_imagepullbackoff_override(self): + pod = { + "status": { + "phase": "Pending", + "containerStatuses": [ + {"state": {"waiting": {"reason": "ImagePullBackOff"}}} + ], + } + } + self.assertEqual(aggregate.classify_pod_status(pod), "ImagePullBackOff") + + def test_errimagepull(self): + pod = { + "status": { + "phase": "Pending", + "containerStatuses": [ + {"state": {"waiting": {"reason": "ErrImagePull"}}} + ], + } + } + self.assertEqual(aggregate.classify_pod_status(pod), "ErrImagePull") + + def test_flat_status_string(self): + pod = {"name": "my-pod", "namespace": "default", "status": "CrashLoopBackOff"} + self.assertEqual(aggregate.classify_pod_status(pod), "CrashLoopBackOff") + + def test_unknown_default(self): + pod = {"status": {}} + self.assertEqual(aggregate.classify_pod_status(pod), "Unknown") + + +class TestAggregatePodsByNamespace(unittest.TestCase): + def test_basic_aggregation(self): + pods = [ + {"metadata": {"namespace": "ns-a"}, "status": {"phase": "Running"}}, + {"metadata": {"namespace": "ns-a"}, "status": {"phase": "Running"}}, + {"metadata": {"namespace": "ns-b"}, "status": {"phase": "Pending"}}, + ] + result = aggregate.aggregate_pods_by_namespace(pods) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["namespace"], "ns-a") + self.assertEqual(result[0]["pods_total"], 2) + self.assertEqual(result[0]["running"], 2) + self.assertEqual(result[1]["namespace"], "ns-b") + self.assertEqual(result[1]["pods_total"], 1) + self.assertEqual(result[1]["pending"], 1) + + def test_top_10_limit(self): + pods = [] + for i in range(15): + for j in range(15 - i): + pods.append({ + "metadata": {"namespace": f"ns-{i:02d}"}, + "status": {"phase": "Running"}, + }) + result = aggregate.aggregate_pods_by_namespace(pods) + self.assertEqual(len(result), 10) + self.assertEqual(result[0]["pods_total"], 15) + self.assertEqual(result[9]["pods_total"], 6) + + def test_empty_pods(self): + self.assertEqual(aggregate.aggregate_pods_by_namespace([]), []) + self.assertEqual(aggregate.aggregate_pods_by_namespace(None), []) + + def test_flat_pod_structure(self): + pods = [ + {"namespace": "ns-a", "status": "Running"}, + {"namespace": "ns-a", "status": "Failed"}, + ] + result = aggregate.aggregate_pods_by_namespace(pods) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["running"], 1) + self.assertEqual(result[0]["failed"], 1) + + +class TestProcessCluster(unittest.TestCase): + def _make_cluster(self, **overrides): + base = { + "context": "test-cluster", + "server": "https://api.test.example.com:6443", + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "pods": None, + "errors": [], + } + base.update(overrides) + return base + + def test_full_data(self): + cluster = self._make_cluster( + nodes_top=[ + {"name": "node-1", "cpu_usage": "4000m", "memory_usage": "16Gi"}, + ], + nodes_list=[ + { + "metadata": { + "name": "node-1", + "labels": {"node-role.kubernetes.io/worker": ""}, + }, + "status": { + "allocatable": {"cpu": "8", "memory": "32Gi", "nvidia.com/gpu": "2"}, + }, + } + ], + projects=[{"name": f"proj-{i}"} for i in range(5)], + pods=[ + {"metadata": {"namespace": "default"}, "status": {"phase": "Running"}}, + {"metadata": {"namespace": "default"}, "status": {"phase": "Running"}}, + {"metadata": {"namespace": "kube-system"}, "status": {"phase": "Failed"}}, + ], + ) + result = aggregate.process_cluster(cluster) + + ov = result["overview"] + self.assertEqual(ov["node_count"], 1) + self.assertEqual(ov["cpu_used_cores"], 4.0) + self.assertEqual(ov["cpu_total_cores"], 8.0) + self.assertEqual(ov["cpu_percent"], 50) + self.assertEqual(ov["gpu_total"], 2) + self.assertEqual(ov["project_count"], 5) + self.assertEqual(ov["pods_running"], 2) + self.assertEqual(ov["pods_total"], 3) + self.assertTrue(ov["metrics_available"]) + + self.assertEqual(result["pod_status"]["Running"], 2) + self.assertEqual(result["pod_status"]["Failed"], 1) + self.assertEqual(len(result["top_namespaces"]), 2) + + def test_no_metrics_server(self): + cluster = self._make_cluster( + nodes_top=None, + nodes_list=[ + { + "metadata": {"name": "node-1", "labels": {}}, + "status": {"allocatable": {"cpu": "8", "memory": "32Gi"}}, + } + ], + ) + result = aggregate.process_cluster(cluster) + + ov = result["overview"] + self.assertFalse(ov["metrics_available"]) + self.assertIsNone(ov["cpu_used_cores"]) + self.assertIsNone(ov["cpu_percent"]) + self.assertEqual(ov["cpu_total_cores"], 8.0) + + def test_empty_cluster(self): + cluster = self._make_cluster() + result = aggregate.process_cluster(cluster) + + ov = result["overview"] + self.assertEqual(ov["node_count"], 0) + self.assertEqual(ov["pods_total"], 0) + self.assertEqual(ov["project_count"], 0) + self.assertEqual(result["pod_status"], {}) + self.assertEqual(result["top_namespaces"], []) + + def test_namespaces_fallback(self): + cluster = self._make_cluster( + projects=None, + namespaces=[{"name": f"ns-{i}"} for i in range(3)], + ) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["project_count"], 3) + + def test_tabular_pods_input(self): + tabular_pods = ( + "NAMESPACE APIVERSION KIND NAME " + "READY STATUS RESTARTS AGE\n" + "default v1 Pod pod-1 " + "1/1 Running 0 1d\n" + "default v1 Pod pod-2 " + "1/1 Running 0 1d\n" + "kube-system v1 Pod pod-3 " + "0/1 Failed 5 3d" + ) + cluster = self._make_cluster(pods=tabular_pods) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["pods_total"], 3) + self.assertEqual(result["overview"]["pods_running"], 2) + self.assertEqual(result["pod_status"]["Running"], 2) + self.assertEqual(result["pod_status"]["Failed"], 1) + self.assertEqual(len(result["top_namespaces"]), 2) + + def test_tabular_projects_input(self): + tabular_projects = ( + "APIVERSION KIND NAME DISPLAY NAME STATUS LABELS\n" + "project.openshift.io/v1 Project proj-1 Project 1 Active \n" + "project.openshift.io/v1 Project proj-2 Project 2 Active \n" + "project.openshift.io/v1 Project proj-3 Active " + ) + cluster = self._make_cluster(projects=tabular_projects) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["project_count"], 3) + + def test_mixed_tabular_and_json(self): + tabular_pods = ( + "NAMESPACE NAME STATUS\n" + "default pod-1 Running" + ) + json_nodes = [{ + "metadata": {"name": "n1", "labels": {}}, + "status": {"allocatable": {"cpu": "4", "memory": "16Gi"}}, + }] + cluster = self._make_cluster( + pods=tabular_pods, nodes_list=json_nodes, + projects=[{"name": "proj-1"}]) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["pods_total"], 1) + self.assertEqual(result["overview"]["node_count"], 1) + self.assertEqual(result["overview"]["project_count"], 1) + + def test_tabular_nodes_list_known_limitation(self): + tabular_nodes = ( + "APIVERSION KIND NAME STATUS ROLES AGE " + "VERSION LABELS\n" + "v1 Node worker-0 Ready worker 30d " + "v1.28 node-role.kubernetes.io/worker=" + ) + cluster = self._make_cluster(nodes_list=tabular_nodes) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["node_count"], 1) + self.assertEqual(result["overview"]["cpu_total_cores"], 0.0) + self.assertEqual(result["overview"]["memory_total_gib"], 0.0) + self.assertEqual(result["overview"]["gpu_total"], 0) + self.assertEqual(result["nodes"][0]["role"], "worker") + + def test_tabular_nodes_top_with_json_nodes_list(self): + tabular_top = ( + "NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)\n" + "node-1 4000m 50% 16Gi 50%" + ) + json_nodes = [{ + "metadata": {"name": "node-1", + "labels": {"node-role.kubernetes.io/worker": ""}}, + "status": {"allocatable": {"cpu": "8", "memory": "32Gi"}}, + }] + cluster = self._make_cluster( + nodes_top=tabular_top, nodes_list=json_nodes) + result = aggregate.process_cluster(cluster) + self.assertEqual(result["overview"]["cpu_used_cores"], 4.0) + self.assertEqual(result["overview"]["cpu_total_cores"], 8.0) + self.assertTrue(result["overview"]["metrics_available"]) + + +class TestComputeTotals(unittest.TestCase): + def test_two_clusters(self): + overview = [ + { + "node_count": 3, "cpu_used_cores": 10.0, "cpu_total_cores": 24.0, + "memory_used_gib": 40.0, "memory_total_gib": 96.0, + "gpu_total": 2, "project_count": 10, + "pods_running": 50, "pods_total": 60, + }, + { + "node_count": 5, "cpu_used_cores": 20.0, "cpu_total_cores": 40.0, + "memory_used_gib": 60.0, "memory_total_gib": 160.0, + "gpu_total": 4, "project_count": 20, + "pods_running": 100, "pods_total": 120, + }, + ] + totals = aggregate.compute_totals(overview) + self.assertEqual(totals["node_count"], 8) + self.assertEqual(totals["cpu_used_cores"], 30.0) + self.assertEqual(totals["cpu_total_cores"], 64.0) + self.assertEqual(totals["cpu_percent"], 47) + self.assertEqual(totals["memory_used_gib"], 100.0) + self.assertEqual(totals["memory_total_gib"], 256.0) + self.assertEqual(totals["memory_percent"], 39) + self.assertEqual(totals["gpu_total"], 6) + self.assertEqual(totals["project_count"], 30) + self.assertEqual(totals["pods_running"], 150) + self.assertEqual(totals["pods_total"], 180) + + def test_mixed_metrics_availability(self): + overview = [ + {"node_count": 3, "cpu_used_cores": 10.0, "cpu_total_cores": 24.0, + "memory_used_gib": 40.0, "memory_total_gib": 96.0, + "gpu_total": 0, "project_count": 5, "pods_running": 20, "pods_total": 25}, + {"node_count": 2, "cpu_used_cores": None, "cpu_total_cores": 16.0, + "memory_used_gib": None, "memory_total_gib": 64.0, + "gpu_total": 0, "project_count": 3, "pods_running": 10, "pods_total": 15}, + ] + totals = aggregate.compute_totals(overview) + self.assertEqual(totals["cpu_used_cores"], 10.0) + self.assertEqual(totals["cpu_total_cores"], 40.0) + + +class TestDetectAttentionItems(unittest.TestCase): + def test_high_cpu(self): + overview = [{"cluster": "prod", "cpu_percent": 90, "memory_percent": 50, + "metrics_available": True, "server": "x"}] + per_cluster = {"prod": {"nodes": [], "pod_status": {}, "errors": []}} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertTrue(any("CPU usage at 90%" in i for i in items)) + + def test_failed_pods(self): + overview = [{"cluster": "prod", "cpu_percent": 50, "memory_percent": 50, + "metrics_available": True, "server": "x"}] + per_cluster = {"prod": {"nodes": [], "pod_status": {"Failed": 3}, "errors": []}} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertTrue(any("3 pods in Failed" in i for i in items)) + + def test_pending_pods(self): + overview = [{"cluster": "dev", "cpu_percent": 30, "memory_percent": 30, + "metrics_available": True, "server": "x"}] + per_cluster = {"dev": {"nodes": [], "pod_status": {"Pending": 5}, "errors": []}} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertTrue(any("5 pods in Pending" in i for i in items)) + + def test_crashloopbackoff(self): + overview = [{"cluster": "prod", "cpu_percent": None, "memory_percent": None, + "metrics_available": False, "server": "x"}] + per_cluster = {"prod": {"nodes": [], "pod_status": {"CrashLoopBackOff": 2}, "errors": []}} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertTrue(any("CrashLoopBackOff" in i for i in items)) + self.assertTrue(any("Metrics Server" in i for i in items)) + + def test_no_issues(self): + overview = [{"cluster": "prod", "cpu_percent": 30, "memory_percent": 40, + "metrics_available": True, "server": "x"}] + per_cluster = {"prod": {"nodes": [], "pod_status": {"Running": 10}, "errors": []}} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertEqual(items, []) + + def test_node_level_high_usage(self): + overview = [{"cluster": "prod", "cpu_percent": 50, "memory_percent": 50, + "metrics_available": True, "server": "x"}] + per_cluster = {"prod": { + "nodes": [{"name": "node-1", "cpu_used": 7.5, "cpu_total": 8.0, + "memory_used": 5.0, "memory_total": 32.0}], + "pod_status": {}, "errors": [], + }} + items = aggregate.detect_attention_items(overview, per_cluster) + self.assertTrue(any("node-1 CPU at 94%" in i for i in items)) + + +class TestFullPipeline(unittest.TestCase): + + def test_two_cluster_report(self): + input_data = { + "generated_at": "2026-03-03T14:30:00Z", + "clusters": { + "prod-us": { + "context": "prod-us", + "server": "https://api.prod-us.example.com:6443", + "nodes_top": [ + {"name": "node-1", "cpu_usage": "4000m", "memory_usage": "16Gi"}, + {"name": "node-2", "cpu_usage": "3000m", "memory_usage": "12Gi"}, + ], + "nodes_list": [ + { + "metadata": {"name": "node-1", + "labels": {"node-role.kubernetes.io/worker": ""}}, + "status": {"allocatable": {"cpu": "8", "memory": "32Gi", + "nvidia.com/gpu": "2"}}, + }, + { + "metadata": {"name": "node-2", + "labels": {"node-role.kubernetes.io/worker": ""}}, + "status": {"allocatable": {"cpu": "8", "memory": "32Gi"}}, + }, + ], + "projects": [{"name": f"proj-{i}"} for i in range(10)], + "pods": [ + {"metadata": {"namespace": "app"}, "status": {"phase": "Running"}} + for _ in range(8) + ] + [ + {"metadata": {"namespace": "app"}, "status": {"phase": "Failed"}} + for _ in range(2) + ], + "errors": [], + }, + "dev-eu": { + "context": "dev-eu", + "server": "https://api.dev-eu.example.com:6443", + "nodes_top": None, + "nodes_list": [ + { + "metadata": {"name": "dev-1", + "labels": {"node-role.kubernetes.io/worker": ""}}, + "status": {"allocatable": {"cpu": "4", "memory": "16Gi"}}, + }, + ], + "projects": [{"name": f"ns-{i}"} for i in range(3)], + "pods": [ + {"metadata": {"namespace": "default"}, "status": {"phase": "Running"}} + for _ in range(5) + ], + "errors": [], + }, + }, + } + + script_path = Path(__file__).parent / "aggregate.py" + proc = subprocess.run( + [sys.executable, str(script_path)], + input=json.dumps(input_data), + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 0, f"Script failed: {proc.stderr}") + + output = json.loads(proc.stdout) + + self.assertEqual(output["generated_at"], "2026-03-03T14:30:00Z") + self.assertEqual(output["clusters_reported"], 2) + self.assertEqual(output["clusters_failed"], 0) + self.assertEqual(len(output["overview"]), 2) + + prod = next(o for o in output["overview"] if o["cluster"] == "prod-us") + self.assertEqual(prod["node_count"], 2) + self.assertEqual(prod["gpu_total"], 2) + self.assertTrue(prod["metrics_available"]) + self.assertEqual(prod["pods_running"], 8) + self.assertEqual(prod["pods_total"], 10) + + dev = next(o for o in output["overview"] if o["cluster"] == "dev-eu") + self.assertFalse(dev["metrics_available"]) + self.assertIsNone(dev["cpu_used_cores"]) + self.assertEqual(dev["node_count"], 1) + + self.assertTrue(any("Failed" in a for a in output["attention"])) + + self.assertEqual(output["totals"]["node_count"], 3) + self.assertEqual(output["totals"]["gpu_total"], 2) + + def test_malformed_input(self): + script_path = Path(__file__).parent / "aggregate.py" + proc = subprocess.run( + [sys.executable, str(script_path)], + input="not valid json{{{", + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 1) + output = json.loads(proc.stdout) + self.assertIn("error", output) + + def test_empty_clusters(self): + script_path = Path(__file__).parent / "aggregate.py" + proc = subprocess.run( + [sys.executable, str(script_path)], + input=json.dumps({"clusters": {}}), + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 1) + output = json.loads(proc.stdout) + self.assertIn("error", output) + + def test_tabular_input_pipeline(self): + input_data = { + "generated_at": "2026-03-03T15:00:00Z", + "clusters": { + "prod": { + "context": "prod", + "server": "https://api.prod.example.com:6443", + "nodes_top": ( + "NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)\n" + "node-1 4000m 50% 16Gi 50%" + ), + "nodes_list": [ + { + "metadata": {"name": "node-1", + "labels": {"node-role.kubernetes.io/worker": ""}}, + "status": {"allocatable": {"cpu": "8", "memory": "32Gi"}}, + } + ], + "projects": ( + "APIVERSION KIND NAME DISPLAY NAME STATUS LABELS\n" + "project.openshift.io/v1 Project proj-1 Active \n" + "project.openshift.io/v1 Project proj-2 Active " + ), + "pods": ( + "NAMESPACE NAME STATUS\n" + "default pod-1 Running\n" + "default pod-2 Running\n" + "kube-system pod-3 Pending" + ), + "namespaces": None, + "errors": [], + } + }, + } + script_path = Path(__file__).parent / "aggregate.py" + proc = subprocess.run( + [sys.executable, str(script_path)], + input=json.dumps(input_data), + capture_output=True, text=True, + ) + self.assertEqual(proc.returncode, 0, f"Script failed: {proc.stderr}") + output = json.loads(proc.stdout) + self.assertEqual(output["clusters_reported"], 1) + prod = output["overview"][0] + self.assertEqual(prod["pods_total"], 3) + self.assertEqual(prod["pods_running"], 2) + self.assertEqual(prod["project_count"], 2) + self.assertEqual(prod["cpu_used_cores"], 4.0) + self.assertEqual(prod["cpu_total_cores"], 8.0) + self.assertTrue(prod["metrics_available"]) + self.assertTrue(any("Pending" in a for a in output["attention"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/ocp-admin/scripts/cluster-report/test_assemble.py b/ocp-admin/scripts/cluster-report/test_assemble.py new file mode 100644 index 00000000..0cd3e9f8 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/test_assemble.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 + +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import assemble + + +class TestUnwrapPersistedOutput(unittest.TestCase): + + def test_single_text_entry(self): + raw = json.dumps([{"type": "text", "text": "NAME STATUS\nnode-1 Ready"}]) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, "NAME STATUS\nnode-1 Ready") + + def test_multiple_text_entries(self): + raw = json.dumps([ + {"type": "text", "text": "part1"}, + {"type": "text", "text": "part2"}, + ]) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, "part1\npart2") + + def test_non_envelope_json_array(self): + data = [{"name": "proj-1"}, {"name": "proj-2"}] + raw = json.dumps(data) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, data) + + def test_envelope_with_no_text_type(self): + raw = json.dumps([{"type": "image", "data": "base64..."}]) + result = assemble.unwrap_persisted_output(raw) + self.assertIsNone(result) + + def test_mixed_types_in_envelope(self): + raw = json.dumps([ + {"type": "text", "text": "hello"}, + {"type": "image", "data": "..."}, + {"type": "text", "text": "world"}, + ]) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, "hello\nworld") + + def test_empty_list(self): + raw = "[]" + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, []) + + def test_string_value(self): + raw = json.dumps("just a string") + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, "just a string") + + def test_dict_value(self): + data = {"key": "value"} + raw = json.dumps(data) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, data) + + def test_non_json_returns_raw_string(self): + raw = "not valid json{{{" + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, raw) + + def test_plain_text_tabular_passthrough(self): + raw = ( + "NAMESPACE APIVERSION KIND NAME READY STATUS\n" + "default v1 Pod web-1 1/1 Running\n" + "default v1 Pod web-2 0/1 Pending\n" + ) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, raw) + + def test_plain_text_oc_format_passthrough(self): + raw = "NAME STATUS ROLES AGE\nnode-1 Ready worker 5d\n" + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, raw) + + def test_large_text_content(self): + big_text = "LINE\n" * 10000 + raw = json.dumps([{"type": "text", "text": big_text}]) + result = assemble.unwrap_persisted_output(raw) + self.assertEqual(result, big_text) + + +class TestResolveFileRef(unittest.TestCase): + + def test_valid_envelope_file(self): + content = json.dumps([{"type": "text", "text": "pod data here"}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(error) + self.assertEqual(result, "pod data here") + finally: + os.unlink(path) + + def test_valid_plain_json_file(self): + data = [{"name": "ns-1"}, {"name": "ns-2"}] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(error) + self.assertEqual(result, data) + finally: + os.unlink(path) + + def test_missing_file(self): + result, error = assemble.resolve_file_ref("/nonexistent/path/file.json") + self.assertIsNone(result) + self.assertIn("not found", error.lower()) + + def test_empty_file(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(result) + self.assertIn("Empty", error) + finally: + os.unlink(path) + + def test_non_json_file_returns_content(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("not json{{{") + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(error) + self.assertEqual(result, "not json{{{") + finally: + os.unlink(path) + + def test_plain_text_tabular_file(self): + text = ( + "NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE\n" + "default v1 Pod web-1 1/1 Running 0 1d\n" + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(text) + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(error) + self.assertEqual(result, text) + finally: + os.unlink(path) + + def test_envelope_with_no_text(self): + content = json.dumps([{"type": "image", "data": "..."}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(result) + self.assertIn("No text content", error) + finally: + os.unlink(path) + + @unittest.skipIf(os.getuid() == 0, "Cannot test permission denied as root") + def test_permission_denied(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("data") + path = f.name + try: + os.chmod(path, 0o000) + result, error = assemble.resolve_file_ref(path) + self.assertIsNone(result) + self.assertIn("Permission denied", error) + finally: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + os.unlink(path) + + +class TestResolveCluster(unittest.TestCase): + + def test_all_inline_passthrough(self): + cluster = { + "context": "test", + "server": "https://test:6443", + "nodes_top": "NAME CPU\nnode1 100m", + "nodes_list": "NAME STATUS\nnode1 Ready", + "projects": [{"name": "p1"}], + "namespaces": None, + "pods": "NS NAME STATUS\ndefault pod1 Running", + "errors": [], + } + original = json.loads(json.dumps(cluster)) + assemble.resolve_cluster(cluster) + self.assertEqual(cluster, original) + + def test_file_ref_resolved(self): + content = json.dumps([{"type": "text", "text": "pod data"}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + cluster = { + "pods": {"$file": path}, + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "errors": [], + } + assemble.resolve_cluster(cluster) + self.assertEqual(cluster["pods"], "pod data") + self.assertEqual(cluster["errors"], []) + finally: + os.unlink(path) + + def test_failed_file_ref_adds_error(self): + cluster = { + "pods": {"$file": "/nonexistent/file.json"}, + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "errors": [], + } + assemble.resolve_cluster(cluster) + self.assertIsNone(cluster["pods"]) + self.assertEqual(len(cluster["errors"]), 1) + self.assertIn("not found", cluster["errors"][0].lower()) + + def test_preserves_existing_errors(self): + cluster = { + "pods": {"$file": "/nonexistent/file.json"}, + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "errors": ["Metrics Server not available"], + } + assemble.resolve_cluster(cluster) + self.assertEqual(len(cluster["errors"]), 2) + self.assertEqual(cluster["errors"][0], "Metrics Server not available") + + def test_mixed_inline_file_null(self): + content = json.dumps([{"type": "text", "text": "node data"}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + cluster = { + "nodes_top": None, + "nodes_list": {"$file": path}, + "projects": [{"name": "p1"}], + "namespaces": None, + "pods": "NS NAME STATUS\ndefault pod1 Running", + "errors": [], + } + assemble.resolve_cluster(cluster) + self.assertIsNone(cluster["nodes_top"]) + self.assertEqual(cluster["nodes_list"], "node data") + self.assertEqual(cluster["projects"], [{"name": "p1"}]) + self.assertEqual(cluster["pods"], "NS NAME STATUS\ndefault pod1 Running") + finally: + os.unlink(path) + + def test_non_data_fields_ignored(self): + cluster = { + "context": {"$file": "/should/not/resolve"}, + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "pods": None, + "errors": [], + } + assemble.resolve_cluster(cluster) + self.assertEqual(cluster["context"], {"$file": "/should/not/resolve"}) + + +class TestFullPipeline(unittest.TestCase): + + SCRIPT = str(Path(__file__).parent / "assemble.py") + + def _run(self, input_data, extra_args=None): + cmd = [sys.executable, self.SCRIPT] + if extra_args: + cmd.extend(extra_args) + proc = subprocess.run( + cmd, + input=json.dumps(input_data), + capture_output=True, text=True, + ) + return proc + + def test_inline_passthrough(self): + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "test": { + "context": "test", + "server": "https://test:6443", + "nodes_top": None, + "nodes_list": None, + "projects": [{"name": "default"}], + "namespaces": None, + "pods": "NS NAME STATUS\ndefault pod1 Running", + "errors": [], + } + }, + } + proc = self._run(manifest) + self.assertEqual(proc.returncode, 0, f"Failed: {proc.stderr}") + output = json.loads(proc.stdout) + cluster = output["clusters"]["test"] + self.assertEqual(cluster["pods"], "NS NAME STATUS\ndefault pod1 Running") + self.assertEqual(cluster["projects"], [{"name": "default"}]) + + def test_file_ref_resolution(self): + content = json.dumps([{"type": "text", "text": "resolved content"}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "test": { + "context": "test", + "server": "https://test:6443", + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "pods": {"$file": path}, + "errors": [], + } + }, + } + proc = self._run(manifest) + self.assertEqual(proc.returncode, 0, f"Failed: {proc.stderr}") + output = json.loads(proc.stdout) + self.assertEqual(output["clusters"]["test"]["pods"], "resolved content") + finally: + os.unlink(path) + + def test_aggregate_flag(self): + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "test": { + "context": "test", + "server": "https://test:6443", + "nodes_top": None, + "nodes_list": None, + "projects": [{"name": "default"}, {"name": "kube-system"}], + "namespaces": None, + "pods": [ + {"namespace": "default", "name": "pod1", "status": "Running"}, + {"namespace": "default", "name": "pod2", "status": "Pending"}, + ], + "errors": [], + } + }, + } + proc = self._run(manifest, extra_args=["--aggregate"]) + self.assertEqual(proc.returncode, 0, f"Failed: {proc.stderr}") + output = json.loads(proc.stdout) + self.assertIn("clusters_reported", output) + self.assertIn("overview", output) + self.assertEqual(output["clusters_reported"], 1) + self.assertEqual(output["overview"][0]["project_count"], 2) + self.assertEqual(output["overview"][0]["pods_total"], 2) + self.assertEqual(output["overview"][0]["pods_running"], 1) + + def test_malformed_manifest(self): + proc = subprocess.run( + [sys.executable, self.SCRIPT], + input="not valid json{{{", + capture_output=True, text=True, + ) + self.assertEqual(proc.returncode, 1) + output = json.loads(proc.stdout) + self.assertIn("error", output) + + def test_file_ref_error_in_pipeline(self): + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "test": { + "context": "test", + "server": "https://test:6443", + "nodes_top": None, + "nodes_list": None, + "projects": None, + "namespaces": None, + "pods": {"$file": "/nonexistent/file.json"}, + "errors": [], + } + }, + } + proc = self._run(manifest) + self.assertEqual(proc.returncode, 0) + output = json.loads(proc.stdout) + cluster = output["clusters"]["test"] + self.assertIsNone(cluster["pods"]) + self.assertTrue(len(cluster["errors"]) > 0) + + def test_end_to_end_with_file_ref_and_aggregate(self): + pods_text = ( + "NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE\n" + "default v1 Pod web-1 1/1 Running 0 1d\n" + "default v1 Pod web-2 1/1 Running 0 1d\n" + "kube-sys v1 Pod dns-1 0/1 Failed 3 2d\n" + ) + content = json.dumps([{"type": "text", "text": pods_text}]) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write(content) + path = f.name + try: + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "prod": { + "context": "prod", + "server": "https://prod:6443", + "nodes_top": None, + "nodes_list": None, + "projects": [{"name": "default"}, {"name": "kube-sys"}], + "namespaces": None, + "pods": {"$file": path}, + "errors": [], + } + }, + } + proc = self._run(manifest, extra_args=["--aggregate"]) + self.assertEqual(proc.returncode, 0, f"Failed: {proc.stderr}") + output = json.loads(proc.stdout) + self.assertEqual(output["clusters_reported"], 1) + ov = output["overview"][0] + self.assertEqual(ov["pods_total"], 3) + self.assertEqual(ov["pods_running"], 2) + self.assertEqual(ov["project_count"], 2) + finally: + os.unlink(path) + + def test_plain_text_file_ref_with_aggregate(self): + pods_text = ( + "NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE\n" + "default v1 Pod web-1 1/1 Running 0 1d\n" + "default v1 Pod web-2 0/1 Pending 0 1h\n" + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(pods_text) + path = f.name + try: + manifest = { + "generated_at": "2026-01-01T00:00:00Z", + "clusters": { + "prod": { + "context": "prod", + "server": "https://prod:6443", + "nodes_top": None, + "nodes_list": None, + "projects": [{"name": "default"}], + "namespaces": None, + "pods": {"$file": path}, + "errors": [], + } + }, + } + proc = self._run(manifest, extra_args=["--aggregate"]) + self.assertEqual(proc.returncode, 0, f"Failed: {proc.stderr}") + output = json.loads(proc.stdout) + self.assertEqual(output["clusters_reported"], 1) + ov = output["overview"][0] + self.assertEqual(ov["pods_total"], 2) + self.assertEqual(ov["pods_running"], 1) + finally: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/ocp-admin/skills/cluster-report/SKILL.md b/ocp-admin/skills/cluster-report/SKILL.md index 676be54a..b928700d 100644 --- a/ocp-admin/skills/cluster-report/SKILL.md +++ b/ocp-admin/skills/cluster-report/SKILL.md @@ -2,13 +2,16 @@ name: cluster-report description: | Generate a consolidated health report across multiple OpenShift clusters. + Verifies each kubeconfig context is a genuine OpenShift cluster before + reporting. Non-OpenShift contexts are skipped by default. Collects node resources (CPU, memory, GPUs), namespace counts, and pod - status from all kubeconfig contexts into a single comparison view. + status into a single comparison view. Use when: - "Show me a report across all clusters" - "Compare cluster health" - "Multi-cluster status overview" - "How are my clusters doing?" + - "Include all clusters including non-OpenShift" (override default filter) NOT for single-cluster deep-dives or troubleshooting specific pods. model: inherit color: cyan @@ -25,248 +28,243 @@ Generate a unified health and resource report across multiple OpenShift/Kubernet **Required MCP Servers**: `openshift` (configured in [.mcp.json](../../.mcp.json)) **Required MCP Tools** (all from `openshift` server): -- `configuration_contexts_list` - Discover available cluster contexts -- `nodes_top` - Node CPU and memory consumption -- `resources_list` - List Kubernetes resources (used for Node specs and GPU detection) -- `namespaces_list` - List namespaces per cluster -- `projects_list` - List OpenShift projects per cluster -- `pods_list` - List pods with status information - -**Required Environment Variables**: -- `KUBECONFIG` - Path to kubeconfig file containing multi-cluster contexts - -**Multi-Cluster Requirement**: The kubeconfig must contain at least one context. For meaningful comparison, two or more cluster contexts are recommended. - -### Prerequisite Validation - -**CRITICAL**: Before executing any operations, validate the environment in Step 0. +- `configuration_contexts_list` — list all kubeconfig contexts and server URLs +- `resources_get` — get a single Kubernetes resource by apiVersion/kind/name +- `nodes_top` — node CPU and memory usage from Metrics Server +- `resources_list` — list Kubernetes resources by apiVersion/kind +- `namespaces_list` — list all namespaces in a cluster +- `projects_list` — list all OpenShift projects +- `pods_list` — list all pods across namespaces + +**Required Environment Variables**: `KUBECONFIG` — must contain at least one cluster context. Two or more recommended for comparison. + +**Helper Scripts** (Python 3, stdlib only — treat as black boxes): +- [`assemble.py`](../../scripts/cluster-report/assemble.py) — resolves `$file` references into complete raw data JSON +- [`aggregate.py`](../../scripts/cluster-report/aggregate.py) — aggregates raw data into structured report JSON + +**CRITICAL Script Rules**: +- **NEVER** read the source code of `aggregate.py` or `assemble.py` +- **NEVER** write ad-hoc Python to parse or transform MCP output +- **NEVER** manually reconstruct data already available in MCP output + +**Verification Steps:** +1. Confirm `openshift` MCP server is available in `.mcp.json` +2. Verify `KUBECONFIG` is set: `test -n "$KUBECONFIG"` (never expose path or contents) +3. If either check fails → Human Notification Protocol + +**Human Notification Protocol:** + +When prerequisites fail: +1. **Stop immediately** — do not make any MCP tool calls +2. **Report error:** + ``` + Cannot execute skill: [specific failure] + Setup: [instructions + link to .mcp.json or KUBECONFIG docs] + ``` +3. **Request decision:** "How to proceed? (setup/skip/abort)" +4. **Wait for user input** + +**Security:** Never display KUBECONFIG path, contents, or any credential values. ## When to Use This Skill -**Use this skill when**: +**Use when**: - Comparing resource utilization across clusters -- Getting a fleet-wide overview of cluster health -- Checking GPU availability across clusters -- Auditing namespace and pod counts across environments +- Getting a fleet-wide health overview - Preparing capacity planning reports -**Do NOT use this skill when**: -- Debugging a specific pod or workload (use `/debug-pod` instead) -- Managing a single cluster's resources -- Deploying or modifying cluster resources +**Do NOT use when**: +- Debugging a specific pod or workload (use `/debug-pod`) ## Workflow ### Step 0: Validate Environment -**Action**: Verify KUBECONFIG is set and the MCP server is accessible. +Check that `KUBECONFIG` is set. **Never expose the path or contents** — only confirm it is set. If not set, stop and instruct the user to `export KUBECONFIG=/path/to/kubeconfig`. -```bash -# Check KUBECONFIG is set (NEVER print its value) -if [ -z "$KUBECONFIG" ]; then - echo "ERROR: KUBECONFIG not set" -else - echo "OK: KUBECONFIG is configured" -fi -``` +### Step 1: Discover and Verify Clusters -**CRITICAL**: Never expose the KUBECONFIG path or its contents to the user. Only confirm it is set. +#### Step 1a: List Contexts -**Handle validation result**: -- **If KUBECONFIG is set**: Continue to Step 1 -- **If KUBECONFIG is not set**: Stop and instruct user: - ``` - KUBECONFIG environment variable is not set. +**MCP Tool**: `configuration_contexts_list` - To configure: - export KUBECONFIG=/path/to/kubeconfig +Collect all context names and server URLs. Do NOT present results to the user yet. - For multi-cluster, merge configs: - export KUBECONFIG=~/.kube/config-cluster1:~/.kube/config-cluster2 - ``` +**Expected Output**: List of context names with associated server URLs. -### Step 1: Discover Available Clusters +**Error Handling**: +- If no contexts found: Stop and instruct user to verify KUBECONFIG points to a valid file with cluster contexts +- If tool call fails: Report MCP server connectivity issue, suggest checking `.mcp.json` configuration -**MCP Tool**: `configuration_contexts_list` (from openshift) +#### Step 1b: Verify OpenShift Clusters -**Purpose**: List all kubeconfig contexts to identify available clusters. +For **each** context discovered in Step 1a, probe for the OpenShift `ClusterVersion` resource: -**Parameters**: None required (discovers all contexts automatically). +**MCP Tool**: `resources_get` -**Expected Response**: -```json -[ - { - "name": "prod-us", - "cluster": "api-prod-us-example-com:6443", - "server": "https://api.prod-us.example.com:6443", - "namespace": "default" - }, - { - "name": "dev-eu", - "cluster": "api-dev-eu-example-com:6443", - "server": "https://api.dev-eu.example.com:6443", - "namespace": "default" - } -] -``` +| Parameter | Value | +|---|---| +| `apiVersion` | `config.openshift.io/v1` | +| `kind` | `ClusterVersion` | +| `name` | `version` | +| `context` | `` | -**Verification Checklist**: -- At least one context is returned -- Server URLs are present for each context -- No authentication errors +**Classification rules**: -**Output to user**: Present the discovered clusters and ask for confirmation. +| Probe Result | Classification | Default Behavior | +|---|---|---| +| Success (resource returned) | **OpenShift** — extract version from `.status.desired.version` | Include | +| 403 Forbidden | **OpenShift (unverified)** — API group exists, RBAC restricts access | Include (version shown as "unknown") | +| 404 / resource not found | **Non-OpenShift** (vanilla Kubernetes or other distribution) | Exclude | +| Timeout / connection refused / 401 | **Unreachable** | Always exclude | -```markdown -## Discovered Clusters +**Performance**: Issue all `resources_get` calls in parallel (one per context) since they are independent. -| # | Context Name | Server | -|---|-------------|--------| -| 1 | prod-us | https://api.prod-us.example.com:6443 | -| 2 | dev-eu | https://api.dev-eu.example.com:6443 | +#### Step 1c: Present Verification Results -Generate report for all clusters, or select specific ones? -``` +Present a categorized summary to the user: -**WAIT**: Do not proceed until user confirms which clusters to include. +```markdown +## Cluster Discovery Results -### Step 2: Collect Cluster Data +### OpenShift Clusters (will be included in report) -For **each selected cluster context**, execute the following data collection steps. Pass `context=` to every tool call. +| Context | Server | OpenShift Version | +|---------|--------|-------------------| +| prod-us | https://api.prod-us.example.com:6443 | 4.16.3 | +| staging | https://api.staging.example.com:6443 | 4.15.12 | -#### Step 2a: Node Resources (CPU/Memory) +### Non-OpenShift Clusters (excluded by default) -**MCP Tool**: `nodes_top` (from openshift) +| Context | Server | Reason | +|---------|--------|--------| +| dev-k8s | https://dev-k8s.example.com:6443 | No ClusterVersion resource (vanilla Kubernetes) | -**Parameters**: -``` -nodes_top(context="prod-us") +### Unreachable Clusters (excluded) + +| Context | Server | Error | +|---------|--------|-------| +| old-cluster | https://old.example.com:6443 | Connection refused | + +**Proceeding with 2 OpenShift clusters.** To include non-OpenShift clusters, say "include all clusters". ``` -**Purpose**: Get CPU and memory consumption for all nodes in the cluster. +**Presentation rules**: +- Omit any section that has no entries (e.g., skip "Non-OpenShift" section if all contexts are OpenShift). +- If ALL contexts are OpenShift, simplify to: "All N contexts are verified OpenShift clusters." +- If ALL contexts are non-OpenShift, inform the user: "No OpenShift clusters found. To include non-OpenShift clusters, say 'include all clusters'." -**Expected Response**: Table or list of nodes with CPU cores used/allocatable and memory used/allocatable. +**User override handling**: -**Key Fields to Extract**: -- Node name -- CPU usage (cores) and CPU capacity -- Memory usage (bytes/GiB) and memory capacity -- Node roles (control-plane, worker, infra) +If the user responds with "include all clusters", "include non-OpenShift", "report on all clusters", or any clear intent to include non-OpenShift contexts, add them back into the selected set. Unreachable clusters are always excluded. -**Error Handling**: If the Metrics Server is not installed, `nodes_top` will fail. In this case: -- Log: `"Cluster : Metrics Server not available — CPU/memory usage data unavailable"` -- Fall back to `resources_list(context=X, apiVersion=v1, kind=Node)` for node count and capacity only (without live usage) -- Mark usage columns as "N/A" in the report +If the user's **original prompt** (before the skill started) already contains phrases like "all clusters", "including non-OpenShift", or "all contexts", pre-select the override and present verification results as: "Including all clusters as requested." -#### Step 2b: GPU Detection +**WAIT**: Do not proceed until user confirms cluster selection. -**MCP Tool**: `resources_list` (from openshift) +### Step 2: Collect Cluster Data -**Parameters**: -``` -resources_list( - context="prod-us", - apiVersion="v1", - kind="Node" -) -``` +For each selected cluster, pass `context=` to every tool call. Collect data using: -**Purpose**: Inspect node `.status.allocatable` for GPU resources. +| Manifest Key | MCP Tool | Extra Parameters | Fallback | +|---|---|---|---| +| `nodes_top` | `nodes_top` | — | Set null if Metrics Server unavailable | +| `nodes_list` | `resources_list` | `apiVersion=v1`, `kind=Node` | — | +| `projects` | `projects_list` | — | Use `namespaces_list` if fails | +| `pods` | `pods_list` | — | — | -**GPU Detection Logic**: -``` -For each node in response: - Check node.status.allocatable for keys: - - "nvidia.com/gpu" - - "amd.com/gpu" - - "intel.com/gpu" - If any GPU key exists and value > 0: - Record: node_name, gpu_type, gpu_count - Else: - gpu_count = 0 -``` +**Error policy**: Skip unreachable clusters. Set failed fields to `null` and append the error to the cluster's `errors` array. Never abort the entire report. -**Note**: This step also provides node capacity data as fallback if Step 2a fails. +#### Persist MCP Output to Files -#### Step 2c: Namespaces / Projects +For each MCP tool call, **immediately save the output to a file** under `/tmp/cluster-report/`. +This ensures data is available for the assembly pipeline regardless of output size. -**MCP Tool**: `projects_list` (from openshift) — preferred for OpenShift clusters +**Naming convention**: `/tmp/cluster-report/-.txt` -**Parameters**: -``` -projects_list(context="prod-us") -``` +Use a sanitized short name for the context (e.g., `prod-us`, `dev-eu`). Create the directory first: -**Fallback**: If `projects_list` fails (vanilla Kubernetes cluster), use: -``` -namespaces_list(context="prod-us") +```bash +mkdir -p /tmp/cluster-report ``` -**Key Fields to Extract**: -- Total count of namespaces/projects -- List of namespace names (for per-namespace pod breakdown) +**How to save**: After each MCP tool call, use Bash to write the output to disk. `$file` references +accept **both plain text and JSON files** — no special formatting is required. -#### Step 2d: Pod Inventory +If Claude Code auto-persisted the output to a file (shown as `persisted-output` in the tool result), +reference that file path directly. -**MCP Tool**: `pods_list` (from openshift) +#### Assemble Manifest -**Parameters**: -``` -pods_list(context="prod-us") +Write the manifest to `/tmp/cluster-report-manifest.json` with `$file` references to the saved files: + +```json +{ + "generated_at": "2026-03-03T14:30:00Z", + "clusters": { + "": { + "context": "", + "server": "", + "cluster_type": "openshift", + "openshift_version": "4.16.3", + "nodes_top": {"$file": "/tmp/cluster-report/-nodes_top.txt"} or null, + "nodes_list": {"$file": "/tmp/cluster-report/-nodes_list.txt"} or null, + "projects": {"$file": "/tmp/cluster-report/-projects.txt"} or null, + "namespaces": {"$file": "/tmp/cluster-report/-namespaces.txt"} or null, + "pods": {"$file": "/tmp/cluster-report/-pods.txt"} or null, + "errors": [""] + } + } +} ``` -**Purpose**: Get all pods across all namespaces with their status. +**Manifest fields from verification**: +- `cluster_type`: `"openshift"` or `"kubernetes"`. Determined during Step 1b verification. +- `openshift_version`: The OpenShift version string (e.g., `"4.16.3"`) or `null` for non-OpenShift clusters. -**Key Fields to Extract**: -- Total pod count -- Status breakdown: Running, Pending, Succeeded, Failed, Unknown -- Per-namespace pod counts (top 10 by pod count for the report) +Fields may also be inlined as raw text strings or set to `null` for failed/unavailable data. -**Pod Status Classification**: -``` -Running → healthy, actively running -Pending → waiting for scheduling or resources -Succeeded → completed successfully (Jobs/CronJobs) -Failed → terminated with error -Unknown → node communication lost +### Step 3: Aggregate Data + +Run the assembly and aggregation pipeline: + +```bash +python3 ocp-admin/scripts/cluster-report/assemble.py --aggregate < /tmp/cluster-report-manifest.json ``` -### Step 3: Generate Unified Report +If the pipeline exits with code 1, display the error JSON to the user and stop. -Assemble collected data into the consolidated report format below. +### Step 4: Render Report -**Report Structure**: +Render the structured JSON output as markdown using this template: ```markdown # Multi-Cluster Report **Generated**: YYYY-MM-DDTHH:MM:SSZ -**Clusters**: clusters reporting +**Clusters**: clusters reporting --- ## Cluster Overview -| Cluster | Nodes | CPU (used/total) | Memory (used/total) | GPUs | Projects | Pods (Running/Total) | -|---------|-------|-------------------|---------------------|------|----------|---------------------| -| prod-us | 12 | 48/96 cores (50%) | 192/384 GiB (50%) | 8 | 45 | 312/320 | -| dev-eu | 4 | 8/32 cores (25%) | 32/128 GiB (25%) | 0 | 12 | 87/92 | -| **Total** | **16** | **56/128 cores (44%)** | **224/512 GiB (44%)** | **8** | **57** | **399/412** | +| Cluster | Version | Nodes | CPU (used/total) | Memory (used/total) | GPUs | Projects | Pods (Running/Total) | +|---------|---------|-------|-------------------|---------------------|------|----------|---------------------| +| prod-us | OCP 4.16.3 | 12 | 48/96 cores (50%) | 192/384 GiB (50%) | 8 | 45 | 312/320 | +| dev-eu | OCP 4.15.12 | 4 | 8/32 cores (25%) | 32/128 GiB (25%) | 0 | 12 | 87/92 | +| **Total** | | **16** | **56/128 cores (44%)** | **224/512 GiB (44%)** | **8** | **57** | **399/412** | --- ## Per-Cluster Details -### () +### () — OpenShift #### Node Resources | Node | Role | CPU Used | CPU Total | Memory Used | Memory Total | GPUs | |------|------|----------|-----------|-------------|--------------|------| | node-1 | worker | 4 cores | 8 cores | 16 GiB | 32 GiB | 2 | -| node-2 | worker | 3 cores | 8 cores | 12 GiB | 32 GiB | 0 | -| node-3 | control-plane | 2 cores | 4 cores | 8 GiB | 16 GiB | 0 | #### Pod Status @@ -283,8 +281,6 @@ Assemble collected data into the consolidated report format below. | Namespace | Pods | Running | Pending | Failed | |-----------|------|---------|---------|--------| | openshift-monitoring | 24 | 24 | 0 | 0 | -| my-app-prod | 18 | 17 | 1 | 0 | -| openshift-ingress | 12 | 12 | 0 | 0 | [Repeat for each cluster] @@ -292,163 +288,98 @@ Assemble collected data into the consolidated report format below. ## Attention Required -List any issues detected: -- Clusters with unreachable nodes -- Pods in Failed or Unknown state -- Nodes with CPU or memory usage > 85% -- Pending pods (possible resource constraints) +[Render each item from the `attention` array] ``` -### Step 4: Offer Next Steps +### Step 5: Offer Next Steps ```markdown ## Next Steps Would you like to: 1. **Drill down** into a specific cluster or namespace -2. **Check alerts** — query Prometheus/Alertmanager for active alerts (requires observability toolset) -3. **Export** this report for sharing -4. **Refresh** — re-run the report with updated data +2. **Check alerts** — query Prometheus/Alertmanager for active alerts +3. **Refresh** — re-run the report with updated data ``` ## Dependencies ### Required MCP Servers -- `openshift` - OpenShift MCP server with multi-cluster support enabled +- `openshift` — with multi-cluster support enabled ### Required MCP Tools - -| Tool | Server | Purpose | -|------|--------|---------| -| `configuration_contexts_list` | openshift | Discover all cluster contexts from kubeconfig | -| `nodes_top` | openshift | Get node CPU/memory metrics from Metrics Server | -| `resources_list` | openshift | List node resources for GPU detection and capacity | -| `namespaces_list` | openshift | List namespaces (Kubernetes fallback) | -| `projects_list` | openshift | List OpenShift projects (preferred) | -| `pods_list` | openshift | List all pods with status across namespaces | +- `configuration_contexts_list` (from openshift) — list all kubeconfig contexts and server URLs +- `resources_get` (from openshift) — get a single Kubernetes resource by apiVersion/kind/name + - Parameters: `apiVersion`, `kind`, `name`, `context` +- `nodes_top` (from openshift) — node CPU and memory usage from Metrics Server + - Parameters: `context` +- `resources_list` (from openshift) — list Kubernetes resources by apiVersion/kind + - Parameters: `apiVersion`, `kind`, `context` +- `namespaces_list` (from openshift) — list all namespaces in a cluster + - Parameters: `context` +- `projects_list` (from openshift) — list all OpenShift projects + - Parameters: `context` +- `pods_list` (from openshift) — list all pods across namespaces + - Parameters: `context` + +### Helper Scripts +- [`ocp-admin/scripts/cluster-report/assemble.py`](../../scripts/cluster-report/assemble.py) +- [`ocp-admin/scripts/cluster-report/aggregate.py`](../../scripts/cluster-report/aggregate.py) ### Related Skills -- None currently (first skill in ocp-admin pack) +- None currently ### Reference Documentation -- [OpenShift MCP Server](https://github.com/openshift/openshift-mcp-server) - MCP server with multi-cluster support -- [Kubernetes MCP Server Tools](https://github.com/containers/kubernetes-mcp-server#tools) - Full tool reference and multi-cluster configuration +- [OpenShift MCP Server](https://github.com/openshift/openshift-mcp-server) +- [Kubernetes MCP Server Tools](https://github.com/containers/kubernetes-mcp-server#tools) ## Error Handling -### Cluster Unreachable - -``` -Cluster : Connection refused - -The cluster at is not reachable. -Possible causes: -1. Cluster is down or API server unavailable -2. VPN not connected -3. Credentials expired — try: oc login - -Continuing with remaining clusters... -``` - -**Behavior**: Skip unreachable cluster, report on remaining clusters. Never fail the entire report due to one cluster being unavailable. - -### Metrics Server Not Available - -``` -Cluster : Metrics Server not installed - -CPU and memory usage data is unavailable. -Node capacity (allocatable) will be shown instead. - -To install Metrics Server: - oc apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml -``` - -**Behavior**: Fall back to node capacity from `resources_list`. Mark usage columns as "N/A". - -### No GPU Resources - -**Behavior**: Display "0" in the GPU column. This is not an error — most clusters do not have GPU nodes. - -### Authentication Expired - -``` -Cluster : Authentication failed (401) - -Your credentials for this cluster have expired. - -To re-authenticate: - oc login - -Or refresh your token: - oc whoami --show-token -``` - -**Behavior**: Skip cluster with auth failure, report on remaining clusters. - -### Empty Cluster - -**Behavior**: Report the cluster with all zeros. Do not skip it — an empty cluster is valid data. - -## Output Variables - -| Variable | Description | Example | -|----------|-------------|---------| -| `CLUSTERS_REPORTED` | Number of clusters successfully queried | `2` | -| `CLUSTERS_FAILED` | Number of clusters that failed | `1` | -| `TOTAL_NODES` | Sum of nodes across all clusters | `16` | -| `TOTAL_GPUS` | Sum of GPUs across all clusters | `8` | -| `TOTAL_PODS` | Sum of pods across all clusters | `412` | -| `ALERTS` | List of attention items detected | `["3 failed pods in prod-us"]` | - -## Examples - -### Example 1: Two-Cluster Report - -**User Request**: "Show me a report across all clusters" - -**Skill Execution**: -1. Validate KUBECONFIG is set: OK -2. Call `configuration_contexts_list()` — discovers: prod-us, dev-eu -3. Present cluster list, user confirms: "all" -4. For prod-us: - - `nodes_top(context="prod-us")` — 12 nodes, 48/96 CPU cores - - `resources_list(context="prod-us", apiVersion="v1", kind="Node")` — 8 GPUs found - - `projects_list(context="prod-us")` — 45 projects - - `pods_list(context="prod-us")` — 320 pods (312 Running, 5 Pending, 3 Failed) -5. For dev-eu: - - `nodes_top(context="dev-eu")` — 4 nodes, 8/32 CPU cores - - `resources_list(context="dev-eu", apiVersion="v1", kind="Node")` — 0 GPUs - - `projects_list(context="dev-eu")` — 12 projects - - `pods_list(context="dev-eu")` — 92 pods (87 Running, 5 Pending) -6. Generate unified report with overview table and per-cluster details -7. Flag: "prod-us: 3 pods in Failed state" in Attention Required section - -### Example 2: Single Cluster with Metrics Server Missing - -**User Request**: "How are my clusters doing?" - -**Skill Execution**: -1. Validate KUBECONFIG is set: OK -2. Call `configuration_contexts_list()` — discovers: staging -3. Present single cluster, user confirms -4. For staging: - - `nodes_top(context="staging")` — **FAILS**: Metrics Server not installed - - Fallback: `resources_list(context="staging", apiVersion="v1", kind="Node")` — 3 nodes, capacity: 24 cores, 96 GiB - - `projects_list(context="staging")` — 8 projects - - `pods_list(context="staging")` — 45 pods (42 Running, 3 Pending) -5. Generate report with "N/A" for CPU/memory usage, show capacity only -6. Note: "Metrics Server not installed — install for live resource usage data" - -### Example 3: Partial Failure - -**User Request**: "Compare cluster health" - -**Skill Execution**: -1. Call `configuration_contexts_list()` — discovers: prod, staging, dev -2. User confirms all three -3. prod: data collected successfully -4. staging: **connection refused** — cluster unreachable -5. dev: data collected successfully -6. Generate report for prod and dev only -7. Attention Required: "staging: Cluster unreachable — connection refused at https://api.staging.example.com:6443" +| Error | Behavior | +|---|---| +| ClusterVersion probe succeeds | Classify as OpenShift, include by default | +| ClusterVersion probe 404/not found | Classify as non-OpenShift, exclude by default | +| ClusterVersion probe 403 Forbidden | Classify as OpenShift (unverified), include by default with version "unknown" | +| ClusterVersion probe timeout/unreachable | Classify as unreachable, always exclude | +| All contexts are non-OpenShift | Inform user, suggest "include all clusters" override | +| User overrides to include non-OpenShift | Proceed normally; `projects_list` may fail (use `namespaces_list` fallback) | +| Cluster unreachable | Skip, continue with remaining clusters | +| Metrics Server missing | Set `nodes_top` to null, show N/A for CPU/memory usage | +| Auth expired (401) | Skip cluster, suggest `oc login ` | +| No GPUs found | Display 0 (not an error) | +| Empty cluster | Report with all zeros (valid data) | + +## Example Usage + +### Multi-Cluster Report (Default: OpenShift Only) + +**User**: "Show me a report across all clusters" + +**Execution**: +1. Validate KUBECONFIG — OK +2. `configuration_contexts_list()` discovers: prod-us, dev-eu, dev-k8s +3. Verify each context with `resources_get(apiVersion="config.openshift.io/v1", kind="ClusterVersion", name="version", context=)` +4. Results: prod-us (OCP 4.16.3), dev-eu (OCP 4.15.12), dev-k8s (non-OpenShift) +5. Present: "2 OpenShift clusters found. dev-k8s excluded (non-OpenShift). Include all?" +6. User confirms default selection +7. Collect data for prod-us and dev-eu only +8. Write manifest with `cluster_type` and `openshift_version` fields +9. Run `assemble.py --aggregate` pipeline +10. Render report with OpenShift version column +11. Flag attention items + +### Multi-Cluster Report (Include All) + +**User**: "Report on all my clusters including non-OpenShift" + +**Execution**: +1. Validate KUBECONFIG — OK +2. `configuration_contexts_list()` discovers: prod-us, dev-eu, dev-k8s +3. Verify each context (same as above) +4. Results: prod-us (OCP 4.16.3), dev-eu (OCP 4.15.12), dev-k8s (non-OpenShift) +5. User's initial message indicates "include all" — present verification results and confirm +6. User confirms all clusters including dev-k8s +7. Collect data for all three clusters (`projects_list` fails on dev-k8s, falls back to `namespaces_list`) +8. Write manifest; dev-k8s has `cluster_type: "kubernetes"`, `openshift_version: null` +9. Run pipeline, render report +10. dev-k8s shown as "K8s" in version column diff --git a/rh-developer/.mcp.json b/rh-developer/.mcp.json index 644cfe91..d119636c 100644 --- a/rh-developer/.mcp.json +++ b/rh-developer/.mcp.json @@ -7,7 +7,6 @@ "--rm", "-i", "--network=host", - "--userns=keep-id:uid=65532,gid=65532", "-v", "${KUBECONFIG}:/kubeconfig:ro,Z", "--entrypoint", "/app/kubernetes-mcp-server", "quay.io/ecosystem-appeng/openshift-mcp-server:latest", diff --git a/rh-developer/README.md b/rh-developer/README.md index cefc18c4..dece2523 100644 --- a/rh-developer/README.md +++ b/rh-developer/README.md @@ -45,6 +45,8 @@ A Claude Code plugin for building and deploying applications on Red Hat platform - **github** - Repository browsing and code analysis - **lightspeed** - Red Hat Insights data (vulnerability, advisor, inventory, planning) — optional +> **Linux users**: For tighter container security, you can add `"--userns=keep-id:uid=65532,gid=65532"` to the openshift MCP server `args` in `.mcp.json`. This maps the container process to a non-root UID. **Do not use this flag on macOS** — Podman runs inside a VM there and the flag will cause startup failures. + ## Supported Languages Node.js, Python, Java, Go, Ruby, .NET, PHP, Perl