From 9eff8b43be7edb3d00e934c0634e355de2a487db Mon Sep 17 00:00:00 2001 From: ikrispin Date: Wed, 4 Mar 2026 19:37:50 +0200 Subject: [PATCH 01/11] add the flag so it can read the owner-ony files --- ocp-admin/.mcp.json | 1 + ocp-admin/README.md | 28 ++++++++++++++++++++++++++++ rh-developer/.mcp.json | 1 + rh-developer/README.md | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ocp-admin/.mcp.json b/ocp-admin/.mcp.json index 1ccf863f..894e7526 100644 --- a/ocp-admin/.mcp.json +++ b/ocp-admin/.mcp.json @@ -7,6 +7,7 @@ "--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 63b564ee..53a37ecf 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -16,6 +16,32 @@ Administration and management tools for OpenShift Container Platform. This pack - OpenShift cluster access via `KUBECONFIG` - For multi-cluster reports, a kubeconfig with multiple contexts +## Multi-Cluster Authentication + +For running `cluster-report` across many clusters (10–100+), use service account tokens instead of interactive `oc login`. This avoids repeated browser-based OAuth sessions and produces non-expiring tokens. + +| Script / Manifest | Purpose | +|-------------------|---------| +| [`build-kubeconfig.sh`](scripts/cluster-report/build-kubeconfig.sh) | Builds a merged kubeconfig from SA tokens (`--setup` + `--build` modes) | +| [`cluster-reporter-rbac.yaml`](scripts/cluster-report/cluster-reporter-rbac.yaml) | Read-only RBAC resources applied once per cluster | + +**Quick start:** + +```bash +# 1. One-time: apply RBAC and extract tokens for all clusters you're logged into +bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh --setup --all-contexts + +# 2. Build merged kubeconfig from saved tokens +bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ + --build --clusters ~/.ocp-clusters/clusters.json --verify + +# 3. Export and run +export KUBECONFIG=/tmp/cluster-report-kubeconfig +# In Claude Code: /cluster-report +``` + +See [docs/multi-cluster-auth.md](docs/multi-cluster-auth.md) for the full setup guide, token rotation, and troubleshooting. + ## Helper Scripts The `cluster-report` skill uses two Python scripts (stdlib only, no dependencies) in `scripts/cluster-report/`: @@ -30,3 +56,5 @@ Both scripts read from stdin and write to stdout. They are invoked as a pipeline ## MCP Servers - **openshift** - OpenShift cluster management with multi-cluster support + +> **Container UID mapping**: The openshift MCP server uses `--userns=keep-id:uid=65532,gid=65532` to map the host user to the container's non-root UID (65532). This allows the container to read `chmod 600` files like `KUBECONFIG` without weakening file permissions. **macOS users**: Podman runs inside a VM on macOS — this flag may cause startup failures. If the MCP server fails to start, remove the `--userns` line from `.mcp.json`. diff --git a/rh-developer/.mcp.json b/rh-developer/.mcp.json index d119636c..644cfe91 100644 --- a/rh-developer/.mcp.json +++ b/rh-developer/.mcp.json @@ -7,6 +7,7 @@ "--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 dece2523..f7711f1f 100644 --- a/rh-developer/README.md +++ b/rh-developer/README.md @@ -45,7 +45,7 @@ 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. +> **Container UID mapping**: The openshift MCP server uses `--userns=keep-id:uid=65532,gid=65532` to map the host user to the container's non-root UID (65532). This allows the container to read `chmod 600` files like `KUBECONFIG` without weakening file permissions. **macOS users**: Podman runs inside a VM on macOS — this flag may cause startup failures. If the MCP server fails to start, remove the `--userns` line from `.mcp.json`. ## Supported Languages From 3b3c7b13e45a6be1f9b788df0a465075c1d83ca2 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Thu, 5 Mar 2026 14:47:54 +0200 Subject: [PATCH 02/11] create an automatic multi-cluster authentication mechanism via SA token and ClusterRole --- ocp-admin/docs/multi-cluster-auth.md | 246 ++++++++++ .../cluster-report/build-kubeconfig.sh | 444 ++++++++++++++++++ .../cluster-report/cluster-reporter-rbac.yaml | 72 +++ ocp-admin/skills/cluster-report/SKILL.md | 4 +- 4 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 ocp-admin/docs/multi-cluster-auth.md create mode 100755 ocp-admin/scripts/cluster-report/build-kubeconfig.sh create mode 100644 ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml diff --git a/ocp-admin/docs/multi-cluster-auth.md b/ocp-admin/docs/multi-cluster-auth.md new file mode 100644 index 00000000..f68ec59d --- /dev/null +++ b/ocp-admin/docs/multi-cluster-auth.md @@ -0,0 +1,246 @@ +# Multi-Cluster Authentication with Service Account Tokens + +Set up non-interactive, long-lived authentication for running `cluster-report` across many OpenShift clusters without repeated `oc login` sessions. + +## Overview + +The `cluster-report` skill requires valid kubeconfig contexts for every cluster it reports on. Interactive `oc login --web` opens a browser for each cluster and produces tokens that expire in ~24 hours which make it difficult to do at scale. + +**Solution**: Create a read-only ServiceAccount on each cluster with a non-expiring token. A builder script assembles these tokens into a single merged kubeconfig that the skill uses unchanged. + +## Prerequisites + +- `oc` or `kubectl` CLI +- `python3` (stdlib only, no extra packages) +- `cluster-admin` access on each target cluster (one-time setup only) + +## Quick Start (Automated) + +If you're currently logged into all the clusters you would like to get a report for via `oc login`: + +```bash +# Step 1: Setup — applies RBAC to each cluster, extracts SA tokens +bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh --setup --all-contexts + +# Step 2: Build — assembles a merged kubeconfig from the inventory +bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ + --build --clusters ~/.ocp-clusters/clusters.json --verify + +# Step 3: Use — export and run the skill +export KUBECONFIG=/tmp/cluster-report-kubeconfig +# Then in Claude Code: /cluster-report +``` + +After the one-time setup, only Steps 2–3 are needed for future report sessions. + +## Manual Setup (Per Cluster) + +If you prefer to set up each cluster individually: + +### 1. Apply RBAC + +```bash +oc login +oc apply -f ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml +``` + +This creates: + +- Namespace `cluster-reporter-system` +- ServiceAccount `cluster-reporter` with a read-only ClusterRole +- Token Secret `cluster-reporter-token` (non-expiring) + +### 2. Extract the Token + +```bash +oc get secret cluster-reporter-token -n cluster-reporter-system \ + -o jsonpath='{.data.token}' | base64 -d +``` + +Save this token securely. It grants read-only access to nodes, pods, namespaces, projects, cluster version, and metrics. + +### 3. Add to Inventory File + +Create or edit `~/.ocp-clusters/clusters.json`: + +```json +{ + "clusters": [ + { + "name": "prod-us-east", + "api_url": "https://api.prod-us-east.example.com:6443", + "token": "sha256~your-token-here" + } + ] +} +``` + +Set permissions: `chmod 600 ~/.ocp-clusters/clusters.json` + +### 4. Build Kubeconfig + +```bash +bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ + --build --clusters ~/.ocp-clusters/clusters.json --output ~/.kube/cluster-report-kubeconfig +``` + +## RBAC Permissions + +The `cluster-reporter-readonly` ClusterRole grants the minimum permissions required by the `cluster-report` skill: + + +| Resource | API Group | Verbs | Used By | +| ----------------------- | -------------------- | --------- | ------------------------------------------------------------- | +| nodes, namespaces, pods | core | get, list | `nodes_top`, `resources_list`, `namespaces_list`, `pods_list` | +| clusterversions | config.openshift.io | get | `resources_get` (OpenShift verification) | +| projects | project.openshift.io | list | `projects_list` | +| nodes, pods (metrics) | metrics.k8s.io | get, list | `nodes_top` | + + +No create, update, delete, or watch permissions are granted. + +## Clusters Inventory Format + +The inventory file (`clusters.json`) supports two token modes: + +### Inline Tokens (Simple) + +```json +{ + "clusters": [ + { + "name": "prod-us-east", + "api_url": "https://api.prod-us-east.example.com:6443", + "token": "sha256~abc123..." + } + ] +} +``` + +The file itself contains secrets — keep it out of git and set `chmod 600`. + +### Environment Variable References (More Secure) + +```json +{ + "clusters": [ + { + "name": "prod-us-east", + "api_url": "https://api.prod-us-east.example.com:6443", + "token_env": "CLUSTER_TOKEN_PROD_US_EAST" + } + ] +} +``` + +The file contains no secrets. Load tokens into environment variables from your secrets manager before running `--build`. + +### Optional: CA Certificate + +```json +{ + "clusters": [ + { + "name": "prod-us-east", + "api_url": "https://api.prod-us-east.example.com:6443", + "token": "sha256~abc123...", + "ca_cert": "/path/to/prod-us-east-ca.crt" + } + ] +} +``` + +If `ca_cert` is omitted, TLS verification is skipped (`--insecure-skip-tls-verify`). + +## Script Reference + +### `--setup` Mode + +```bash +bash build-kubeconfig.sh --setup [OPTIONS] +``` + + +| Flag | Description | Default | +| --------------------------- | ----------------------------- | ------------------------------- | +| `--all-contexts` | Setup all kubeconfig contexts | Lists contexts and exits | +| `--contexts ctx1,ctx2` | Setup only specified contexts | — | +| `--output-inventory ` | Inventory file path | `~/.ocp-clusters/clusters.json` | + + +Behavior: + +- Applies `cluster-reporter-rbac.yaml` to each cluster +- Waits up to 15 seconds for the token Secret to populate +- Extracts and saves the token to the inventory file +- Skips unreachable clusters with an error message +- Appends to existing inventory (deduplicates by name) + +### `--build` Mode + +```bash +bash build-kubeconfig.sh --build --clusters [OPTIONS] +``` + + +| Flag | Description | Default | +| ------------------- | -------------------------------- | -------------------------------- | +| `--clusters ` | Inventory file path (required) | — | +| `--output ` | Kubeconfig output path | `/tmp/cluster-report-kubeconfig` | +| `--verify` | Test each context after building | Off | + + +Behavior: + +- Reads inventory, resolves tokens (inline or env var) +- Builds kubeconfig with `kubectl config set-cluster/set-credentials/set-context` +- Partial success: continues on individual failures +- `--verify` tests each context with `cluster-info` +- Outputs JSON summary with success/error counts + +## Token Rotation + +SA token Secrets do not expire, but you may want to rotate them periodically: + +```bash +# On each cluster that needs rotation: +oc delete secret cluster-reporter-token -n cluster-reporter-system +oc apply -f ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml + +# Extract the new token: +oc get secret cluster-reporter-token -n cluster-reporter-system \ + -o jsonpath='{.data.token}' | base64 -d + +# Update clusters.json with the new token, then rebuild: +bash build-kubeconfig.sh --build --clusters ~/.ocp-clusters/clusters.json --verify +``` + +To detect expired or invalid tokens: + +```bash +bash build-kubeconfig.sh --build --clusters ~/.ocp-clusters/clusters.json --verify +# Failed verifications indicate clusters needing token rotation +``` + +## Security Best Practices + +1. **Never commit tokens to git** — add `clusters.json` to `.gitignore` +2. **File permissions** — `chmod 600` on both `clusters.json` and the generated kubeconfig +3. **Prefer `token_env`** — store actual tokens in a secrets manager, not in files +4. **Minimum RBAC** — the ClusterRole grants read-only access only +5. **Dedicated namespace** — the SA lives in `cluster-reporter-system`, not `kube-system` +6. **Generated kubeconfig is ephemeral** — `/tmp/` is fine for session use; for persistent storage use `~/.kube/` with `chmod 600` + +## Troubleshooting + + +| Problem | Cause | Fix | +| ---------------------------------------- | ----------------------------------------- | ------------------------------------------------------------- | +| `--setup` skips a cluster | Not logged in or auth expired | `oc login ` first, then re-run setup | +| `--verify` fails for a cluster | Token expired or Secret deleted | Re-run `--setup --contexts ` for that cluster | +| `cluster-report` shows 401 for a cluster | Token invalid | Same as above — re-run setup for that cluster | +| `cluster-report` shows 403 | SA missing permissions | Re-apply `cluster-reporter-rbac.yaml` on that cluster | +| Token Secret not populated | Token controller slow or SA doesn't exist | Wait and retry; verify SA exists in `cluster-reporter-system` | +| `--build` says "env var not set" | Using `token_env` but env not loaded | Export the token env vars before running `--build` | + + diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.sh b/ocp-admin/scripts/cluster-report/build-kubeconfig.sh new file mode 100755 index 00000000..697164ed --- /dev/null +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# build-kubeconfig.sh — Multi-cluster kubeconfig builder for cluster-report +# +# Two modes: +# --setup Apply RBAC and extract SA tokens for clusters you're logged into +# --build Build a merged kubeconfig from a clusters inventory file +# +# Usage: +# bash build-kubeconfig.sh --setup [--all-contexts] [--contexts ctx1,ctx2] +# [--output-inventory ] +# +# bash build-kubeconfig.sh --build --clusters +# [--output ] [--verify] +# +# Requires: oc or kubectl, python3 (stdlib only) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RBAC_MANIFEST="${SCRIPT_DIR}/cluster-reporter-rbac.yaml" + +MODE="" +CLUSTERS_FILE="" +OUTPUT_FILE="/tmp/cluster-report-kubeconfig" +INVENTORY_FILE="${HOME}/.ocp-clusters/clusters.json" +VERIFY=false +ALL_CONTEXTS=false +SELECTED_CONTEXTS="" + +# --- Argument parsing --- + +usage() { + cat <<'USAGE' +Usage: + build-kubeconfig.sh --setup [--all-contexts] [--contexts ctx1,ctx2] + [--output-inventory ] + + build-kubeconfig.sh --build --clusters + [--output ] [--verify] + +Modes: + --setup Apply RBAC to clusters you're logged into, extract SA tokens, + and write a clusters inventory file. + --build Read a clusters inventory file and build a merged kubeconfig. + +Setup options: + --all-contexts Setup all kubeconfig contexts without prompting. + --contexts c1,c2 Setup only the specified contexts (comma-separated). + --output-inventory Path for the clusters inventory file. + Default: ~/.ocp-clusters/clusters.json + +Build options: + --clusters Path to the clusters inventory JSON file (required). + --output Path for the generated kubeconfig. + Default: /tmp/cluster-report-kubeconfig + --verify Test each context after building the kubeconfig. +USAGE + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --setup) MODE="setup"; shift ;; + --build) MODE="build"; shift ;; + --clusters) CLUSTERS_FILE="$2"; shift 2 ;; + --output) OUTPUT_FILE="$2"; shift 2 ;; + --output-inventory) INVENTORY_FILE="$2"; shift 2 ;; + --verify) VERIFY=true; shift ;; + --all-contexts) ALL_CONTEXTS=true; shift ;; + --contexts) SELECTED_CONTEXTS="$2"; shift 2 ;; + -h|--help) usage ;; + *) echo "Unknown flag: $1" >&2; usage ;; + esac +done + +if [[ -z "$MODE" ]]; then + echo "Error: specify --setup or --build" >&2 + usage +fi + +# --- Helpers --- + +KUBE_CMD="" +if command -v oc &>/dev/null; then + KUBE_CMD="oc" +elif command -v kubectl &>/dev/null; then + KUBE_CMD="kubectl" +else + echo '{"error": "Neither oc nor kubectl found in PATH"}' >&2 + exit 1 +fi + +SA_NAMESPACE="cluster-reporter-system" +SA_NAME="cluster-reporter" +SECRET_NAME="cluster-reporter-token" + +# --- Setup mode --- + +run_setup() { + if [[ ! -f "$RBAC_MANIFEST" ]]; then + echo "Error: RBAC manifest not found at ${RBAC_MANIFEST}" >&2 + exit 1 + fi + + mapfile -t ALL_CTX < <($KUBE_CMD config get-contexts -o name 2>/dev/null) + + if [[ ${#ALL_CTX[@]} -eq 0 ]]; then + echo '{"error": "No kubeconfig contexts found. Log in to at least one cluster first."}' >&2 + exit 1 + fi + + CONTEXTS_TO_PROCESS=() + + if [[ -n "$SELECTED_CONTEXTS" ]]; then + IFS=',' read -ra CONTEXTS_TO_PROCESS <<< "$SELECTED_CONTEXTS" + elif [[ "$ALL_CONTEXTS" == "true" ]]; then + CONTEXTS_TO_PROCESS=("${ALL_CTX[@]}") + else + echo "Available contexts:" + for i in "${!ALL_CTX[@]}"; do + echo " $((i+1)). ${ALL_CTX[$i]}" + done + echo "" + echo "Run with --all-contexts to setup all, or --contexts ctx1,ctx2 to select specific ones." + exit 0 + fi + + echo "Setting up ${#CONTEXTS_TO_PROCESS[@]} cluster(s)..." + echo "" + + INVENTORY_DIR="$(dirname "$INVENTORY_FILE")" + mkdir -p "$INVENTORY_DIR" + + EXISTING_CLUSTERS="[]" + if [[ -f "$INVENTORY_FILE" ]]; then + EXISTING_CLUSTERS=$(python3 -c " +import json, sys +with open('${INVENTORY_FILE}') as f: + data = json.load(f) +json.dump(data.get('clusters', []), sys.stdout) +" 2>/dev/null || echo "[]") + fi + + python3 - "$KUBE_CMD" "$RBAC_MANIFEST" "$SA_NAMESPACE" "$SECRET_NAME" "$INVENTORY_FILE" "$EXISTING_CLUSTERS" "${CONTEXTS_TO_PROCESS[@]}" <<'SETUP_SCRIPT' +import subprocess, sys, json, time, os + +kube_cmd = sys.argv[1] +rbac_manifest = sys.argv[2] +sa_namespace = sys.argv[3] +secret_name = sys.argv[4] +inventory_file = sys.argv[5] +existing_clusters = json.loads(sys.argv[6]) +contexts = sys.argv[7:] + +# Index existing clusters by name for dedup +existing_by_name = {c["name"]: c for c in existing_clusters} + +results = {"setup": [], "errors": []} + +for ctx in contexts: + print(f"--- {ctx} ---") + + # Get server URL for this context + try: + server = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.clusters[?(@.name==\"{ctx}\")].cluster.server}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + + if not server: + # Try matching by context's cluster reference + cluster_ref = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.contexts[?(@.name==\"{ctx}\")].context.cluster}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + if cluster_ref: + server = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.clusters[?(@.name==\"{cluster_ref}\")].cluster.server}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + except subprocess.CalledProcessError: + results["errors"].append(f"{ctx}: failed to get server URL") + print(f" SKIP: cannot determine server URL") + continue + + if not server: + results["errors"].append(f"{ctx}: no server URL found in kubeconfig") + print(f" SKIP: no server URL") + continue + + # Check connectivity + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", ctx], + capture_output=True, text=True, timeout=15, check=True + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + results["errors"].append(f"{ctx}: cluster unreachable or auth expired") + print(f" SKIP: cluster unreachable (try 'oc login {server}' first)") + continue + + print(f" Server: {server}") + + # Apply RBAC manifest + print(f" Applying RBAC...") + try: + subprocess.run( + [kube_cmd, "apply", "-f", rbac_manifest, "--context", ctx], + capture_output=True, text=True, timeout=30, check=True + ) + except subprocess.CalledProcessError as e: + results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") + print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") + continue + + # Wait for token to be populated (up to 15 seconds) + print(f" Waiting for token...") + token = "" + for attempt in range(15): + try: + token = subprocess.check_output( + [kube_cmd, "get", "secret", secret_name, + "-n", sa_namespace, "--context", ctx, + "-o", "jsonpath={.data.token}"], + text=True, stderr=subprocess.DEVNULL, timeout=10 + ).strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + + if token: + break + time.sleep(1) + + if not token: + results["errors"].append(f"{ctx}: token not populated after 15s") + print(f" FAIL: token Secret not populated") + continue + + # Decode base64 token + import base64 + try: + decoded_token = base64.b64decode(token).decode("utf-8") + except Exception: + decoded_token = token # might already be decoded + + # Add to inventory + entry = { + "name": ctx, + "api_url": server, + "token": decoded_token + } + existing_by_name[ctx] = entry + results["setup"].append(ctx) + print(f" OK: token extracted") + +# Write inventory +all_clusters = list(existing_by_name.values()) +inventory = {"clusters": all_clusters} + +with open(inventory_file, "w") as f: + json.dump(inventory, f, indent=2) +os.chmod(inventory_file, 0o600) + +print("") +print("=" * 50) +print(f"Setup complete: {len(results['setup'])} succeeded, {len(results['errors'])} failed") +if results["errors"]: + print(f"Errors:") + for e in results["errors"]: + print(f" - {e}") +print(f"Inventory written to: {inventory_file}") +print("") +print("Next step:") +print(f" bash build-kubeconfig.sh --build --clusters {inventory_file} --verify") + +# Output JSON summary to stderr for scripting +json.dump(results, sys.stderr, indent=2) +SETUP_SCRIPT +} + +# --- Build mode --- + +run_build() { + if [[ -z "$CLUSTERS_FILE" ]]; then + echo "Error: --clusters is required for --build mode" >&2 + usage + fi + + if [[ ! -f "$CLUSTERS_FILE" ]]; then + echo "{\"error\": \"Clusters file not found: ${CLUSTERS_FILE}\"}" >&2 + exit 1 + fi + + rm -f "$OUTPUT_FILE" + touch "$OUTPUT_FILE" + chmod 600 "$OUTPUT_FILE" + + python3 - "$KUBE_CMD" "$CLUSTERS_FILE" "$OUTPUT_FILE" "$VERIFY" <<'BUILD_SCRIPT' +import json, sys, os, subprocess + +kube_cmd = sys.argv[1] +clusters_file = sys.argv[2] +output_file = sys.argv[3] +verify = sys.argv[4] == "True" + +with open(clusters_file) as f: + config = json.load(f) + +clusters = config.get("clusters", []) +if not clusters: + print('{"error": "No clusters in inventory file"}', file=sys.stderr) + sys.exit(1) + +env = {**os.environ, "KUBECONFIG": output_file} +errors = [] +success = 0 + +for c in clusters: + name = c.get("name", "") + api_url = c.get("api_url", "") + + if not name or not api_url: + errors.append(f"Entry missing name or api_url: {c}") + continue + + # Resolve token + token = None + if "token_env" in c: + token = os.environ.get(c["token_env"]) + if not token: + errors.append(f"{name}: env var {c['token_env']} not set") + continue + elif "token" in c: + token = c["token"] + else: + errors.append(f"{name}: no token or token_env specified") + continue + + # Set cluster + ca_args = [] + if "ca_cert" in c and c["ca_cert"]: + ca_args = ["--certificate-authority", c["ca_cert"]] + else: + ca_args = ["--insecure-skip-tls-verify=true"] + + try: + subprocess.run( + [kube_cmd, "config", "set-cluster", name, + "--server", api_url] + ca_args, + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-cluster failed: {e.stderr.decode().strip()}") + continue + + # Set credentials + try: + subprocess.run( + [kube_cmd, "config", "set-credentials", f"{name}-reporter", + "--token", token], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-credentials failed: {e.stderr.decode().strip()}") + continue + + # Set context + try: + subprocess.run( + [kube_cmd, "config", "set-context", name, + "--cluster", name, + "--user", f"{name}-reporter"], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-context failed: {e.stderr.decode().strip()}") + continue + + # Set first successful context as current-context (required by MCP server) + if success == 0: + subprocess.run( + [kube_cmd, "config", "use-context", name], + check=False, capture_output=True, env=env + ) + + success += 1 + +# Verify if requested +verify_results = {} +if verify and success > 0: + print(f"Verifying {success} context(s)...") + for c in clusters: + name = c.get("name", "") + if not name: + continue + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", name], + capture_output=True, text=True, timeout=15, check=True, env=env + ) + verify_results[name] = "ok" + print(f" {name}: OK") + except subprocess.TimeoutExpired: + verify_results[name] = "timeout" + errors.append(f"{name}: verification timed out") + print(f" {name}: TIMEOUT") + except subprocess.CalledProcessError as e: + verify_results[name] = "failed" + errors.append(f"{name}: verification failed (likely expired token)") + print(f" {name}: FAILED (re-run --setup for this cluster)") + +result = { + "clusters_configured": success, + "clusters_failed": len(errors), + "kubeconfig": output_file, + "errors": errors +} +if verify: + result["verification"] = verify_results + +print("") +print(json.dumps(result, indent=2)) + +if success == 0: + sys.exit(1) +BUILD_SCRIPT + + echo "" + echo "Kubeconfig written to: ${OUTPUT_FILE}" + echo "" + echo "To use with cluster-report:" + echo " export KUBECONFIG=${OUTPUT_FILE}" +} + +# --- Main --- + +case "$MODE" in + setup) run_setup ;; + build) run_build ;; + *) usage ;; +esac diff --git a/ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml b/ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml new file mode 100644 index 00000000..4fe9eeb8 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml @@ -0,0 +1,72 @@ +--- +# cluster-reporter-rbac.yaml +# One-time per-cluster setup for multi-cluster reporting with SA tokens. +# Apply with: oc apply -f cluster-reporter-rbac.yaml +# +# Creates a read-only ServiceAccount with the minimum permissions required +# by the cluster-report skill. The token Secret does not expire until deleted. +# +# After applying, extract the token: +# oc get secret cluster-reporter-token -n cluster-reporter-system \ +# -o jsonpath='{.data.token}' | base64 -d + +apiVersion: v1 +kind: Namespace +metadata: + name: cluster-reporter-system + labels: + app.kubernetes.io/part-of: cluster-reporter +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: cluster-reporter + namespace: cluster-reporter-system + labels: + app.kubernetes.io/part-of: cluster-reporter +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cluster-reporter-readonly + labels: + app.kubernetes.io/part-of: cluster-reporter +rules: + - apiGroups: [""] + resources: ["nodes", "namespaces", "pods"] + verbs: ["get", "list"] + - apiGroups: ["config.openshift.io"] + resources: ["clusterversions"] + verbs: ["get"] + - apiGroups: ["project.openshift.io"] + resources: ["projects"] + verbs: ["list"] + - apiGroups: ["metrics.k8s.io"] + resources: ["nodes", "pods"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: cluster-reporter-binding + labels: + app.kubernetes.io/part-of: cluster-reporter +subjects: + - kind: ServiceAccount + name: cluster-reporter + namespace: cluster-reporter-system +roleRef: + kind: ClusterRole + name: cluster-reporter-readonly + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Secret +metadata: + name: cluster-reporter-token + namespace: cluster-reporter-system + annotations: + kubernetes.io/service-account.name: cluster-reporter + labels: + app.kubernetes.io/part-of: cluster-reporter +type: kubernetes.io/service-account-token diff --git a/ocp-admin/skills/cluster-report/SKILL.md b/ocp-admin/skills/cluster-report/SKILL.md index b928700d..6412d948 100644 --- a/ocp-admin/skills/cluster-report/SKILL.md +++ b/ocp-admin/skills/cluster-report/SKILL.md @@ -38,6 +38,8 @@ Generate a unified health and resource report across multiple OpenShift/Kubernet **Required Environment Variables**: `KUBECONFIG` — must contain at least one cluster context. Two or more recommended for comparison. +**Multi-Cluster Setup**: For large-scale deployments using service account tokens instead of interactive `oc login`, see [multi-cluster-auth.md](../../docs/multi-cluster-auth.md) and the [build-kubeconfig.sh](../../scripts/cluster-report/build-kubeconfig.sh) helper script. + **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 @@ -345,7 +347,7 @@ Would you like to: | 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 ` | +| Auth expired (401) | Skip cluster, suggest: re-run `build-kubeconfig.sh --build --verify` or `oc login ` | | No GPUs found | Display 0 (not an error) | | Empty cluster | Report with all zeros (valid data) | From 765465dac063d53265c5f505aa4de50ee8658bc5 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Sun, 8 Mar 2026 14:06:54 +0200 Subject: [PATCH 03/11] add explicit rule to never display tokens in AI conversations --- ocp-admin/docs/multi-cluster-auth.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ocp-admin/docs/multi-cluster-auth.md b/ocp-admin/docs/multi-cluster-auth.md index f68ec59d..85e95797 100644 --- a/ocp-admin/docs/multi-cluster-auth.md +++ b/ocp-admin/docs/multi-cluster-auth.md @@ -59,6 +59,8 @@ oc get secret cluster-reporter-token -n cluster-reporter-system \ Save this token securely. It grants read-only access to nodes, pods, namespaces, projects, cluster version, and metrics. +> **AI Safety**: Never display token values in conversation output. Verify tokens are set, but never print or echo their contents. + ### 3. Add to Inventory File Create or edit `~/.ocp-clusters/clusters.json`: @@ -230,6 +232,7 @@ bash build-kubeconfig.sh --build --clusters ~/.ocp-clusters/clusters.json --veri 4. **Minimum RBAC** — the ClusterRole grants read-only access only 5. **Dedicated namespace** — the SA lives in `cluster-reporter-system`, not `kube-system` 6. **Generated kubeconfig is ephemeral** — `/tmp/` is fine for session use; for persistent storage use `~/.kube/` with `chmod 600` +7. **Never display tokens in AI conversations** — verify tokens are set but never print, echo, or expose their values in output ## Troubleshooting From fa0b9a1fa6e23000e9b1d745a69c2d381feb66e8 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Sun, 8 Mar 2026 14:13:00 +0200 Subject: [PATCH 04/11] docs: clarified the admin permission level as a prerequisite --- ocp-admin/README.md | 4 +++- ocp-admin/docs/multi-cluster-auth.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ocp-admin/README.md b/ocp-admin/README.md index 53a37ecf..7c0a739c 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -25,10 +25,12 @@ For running `cluster-report` across many clusters (10–100+), use service accou | [`build-kubeconfig.sh`](scripts/cluster-report/build-kubeconfig.sh) | Builds a merged kubeconfig from SA tokens (`--setup` + `--build` modes) | | [`cluster-reporter-rbac.yaml`](scripts/cluster-report/cluster-reporter-rbac.yaml) | Read-only RBAC resources applied once per cluster | +> **Required permissions**: The RBAC setup creates cluster-scoped resources (ClusterRole, ClusterRoleBinding), so the user running `--setup` needs `cluster-admin` privileges. This is a one-time step per cluster. + **Quick start:** ```bash -# 1. One-time: apply RBAC and extract tokens for all clusters you're logged into +# 1. One-time (requires cluster-admin): apply RBAC and extract tokens for all clusters you're logged into bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh --setup --all-contexts # 2. Build merged kubeconfig from saved tokens diff --git a/ocp-admin/docs/multi-cluster-auth.md b/ocp-admin/docs/multi-cluster-auth.md index 85e95797..7840e676 100644 --- a/ocp-admin/docs/multi-cluster-auth.md +++ b/ocp-admin/docs/multi-cluster-auth.md @@ -39,6 +39,8 @@ If you prefer to set up each cluster individually: ### 1. Apply RBAC +> **Required permissions**: The manifest creates cluster-scoped resources (ClusterRole, ClusterRoleBinding), so the user applying it needs `cluster-admin` privileges. This is a one-time setup step. + ```bash oc login oc apply -f ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml @@ -48,6 +50,7 @@ This creates: - Namespace `cluster-reporter-system` - ServiceAccount `cluster-reporter` with a read-only ClusterRole +- ClusterRoleBinding `cluster-reporter-binding` (binds the SA to the ClusterRole) - Token Secret `cluster-reporter-token` (non-expiring) ### 2. Extract the Token From e872bc5202806a7344afb257d16c7ea4bf168443 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Sun, 8 Mar 2026 14:20:13 +0200 Subject: [PATCH 05/11] extrat python setup and build scripts --- .../cluster-report/build-kubeconfig.sh | 269 +----------------- .../cluster-report/build-merged-kubeconfig.py | 139 +++++++++ .../scripts/cluster-report/setup-clusters.py | 146 ++++++++++ 3 files changed, 287 insertions(+), 267 deletions(-) create mode 100644 ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py create mode 100644 ocp-admin/scripts/cluster-report/setup-clusters.py diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.sh b/ocp-admin/scripts/cluster-report/build-kubeconfig.sh index 697164ed..0bc3bd3f 100755 --- a/ocp-admin/scripts/cluster-report/build-kubeconfig.sh +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.sh @@ -141,144 +141,7 @@ json.dump(data.get('clusters', []), sys.stdout) " 2>/dev/null || echo "[]") fi - python3 - "$KUBE_CMD" "$RBAC_MANIFEST" "$SA_NAMESPACE" "$SECRET_NAME" "$INVENTORY_FILE" "$EXISTING_CLUSTERS" "${CONTEXTS_TO_PROCESS[@]}" <<'SETUP_SCRIPT' -import subprocess, sys, json, time, os - -kube_cmd = sys.argv[1] -rbac_manifest = sys.argv[2] -sa_namespace = sys.argv[3] -secret_name = sys.argv[4] -inventory_file = sys.argv[5] -existing_clusters = json.loads(sys.argv[6]) -contexts = sys.argv[7:] - -# Index existing clusters by name for dedup -existing_by_name = {c["name"]: c for c in existing_clusters} - -results = {"setup": [], "errors": []} - -for ctx in contexts: - print(f"--- {ctx} ---") - - # Get server URL for this context - try: - server = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.clusters[?(@.name==\"{ctx}\")].cluster.server}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - - if not server: - # Try matching by context's cluster reference - cluster_ref = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.contexts[?(@.name==\"{ctx}\")].context.cluster}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - if cluster_ref: - server = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.clusters[?(@.name==\"{cluster_ref}\")].cluster.server}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - except subprocess.CalledProcessError: - results["errors"].append(f"{ctx}: failed to get server URL") - print(f" SKIP: cannot determine server URL") - continue - - if not server: - results["errors"].append(f"{ctx}: no server URL found in kubeconfig") - print(f" SKIP: no server URL") - continue - - # Check connectivity - try: - subprocess.run( - [kube_cmd, "cluster-info", "--context", ctx], - capture_output=True, text=True, timeout=15, check=True - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - results["errors"].append(f"{ctx}: cluster unreachable or auth expired") - print(f" SKIP: cluster unreachable (try 'oc login {server}' first)") - continue - - print(f" Server: {server}") - - # Apply RBAC manifest - print(f" Applying RBAC...") - try: - subprocess.run( - [kube_cmd, "apply", "-f", rbac_manifest, "--context", ctx], - capture_output=True, text=True, timeout=30, check=True - ) - except subprocess.CalledProcessError as e: - results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") - print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") - continue - - # Wait for token to be populated (up to 15 seconds) - print(f" Waiting for token...") - token = "" - for attempt in range(15): - try: - token = subprocess.check_output( - [kube_cmd, "get", "secret", secret_name, - "-n", sa_namespace, "--context", ctx, - "-o", "jsonpath={.data.token}"], - text=True, stderr=subprocess.DEVNULL, timeout=10 - ).strip() - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - pass - - if token: - break - time.sleep(1) - - if not token: - results["errors"].append(f"{ctx}: token not populated after 15s") - print(f" FAIL: token Secret not populated") - continue - - # Decode base64 token - import base64 - try: - decoded_token = base64.b64decode(token).decode("utf-8") - except Exception: - decoded_token = token # might already be decoded - - # Add to inventory - entry = { - "name": ctx, - "api_url": server, - "token": decoded_token - } - existing_by_name[ctx] = entry - results["setup"].append(ctx) - print(f" OK: token extracted") - -# Write inventory -all_clusters = list(existing_by_name.values()) -inventory = {"clusters": all_clusters} - -with open(inventory_file, "w") as f: - json.dump(inventory, f, indent=2) -os.chmod(inventory_file, 0o600) - -print("") -print("=" * 50) -print(f"Setup complete: {len(results['setup'])} succeeded, {len(results['errors'])} failed") -if results["errors"]: - print(f"Errors:") - for e in results["errors"]: - print(f" - {e}") -print(f"Inventory written to: {inventory_file}") -print("") -print("Next step:") -print(f" bash build-kubeconfig.sh --build --clusters {inventory_file} --verify") - -# Output JSON summary to stderr for scripting -json.dump(results, sys.stderr, indent=2) -SETUP_SCRIPT + python3 "${SCRIPT_DIR}/setup-clusters.py" "$KUBE_CMD" "$RBAC_MANIFEST" "$SA_NAMESPACE" "$SECRET_NAME" "$INVENTORY_FILE" "$EXISTING_CLUSTERS" "${CONTEXTS_TO_PROCESS[@]}" } # --- Build mode --- @@ -298,135 +161,7 @@ run_build() { touch "$OUTPUT_FILE" chmod 600 "$OUTPUT_FILE" - python3 - "$KUBE_CMD" "$CLUSTERS_FILE" "$OUTPUT_FILE" "$VERIFY" <<'BUILD_SCRIPT' -import json, sys, os, subprocess - -kube_cmd = sys.argv[1] -clusters_file = sys.argv[2] -output_file = sys.argv[3] -verify = sys.argv[4] == "True" - -with open(clusters_file) as f: - config = json.load(f) - -clusters = config.get("clusters", []) -if not clusters: - print('{"error": "No clusters in inventory file"}', file=sys.stderr) - sys.exit(1) - -env = {**os.environ, "KUBECONFIG": output_file} -errors = [] -success = 0 - -for c in clusters: - name = c.get("name", "") - api_url = c.get("api_url", "") - - if not name or not api_url: - errors.append(f"Entry missing name or api_url: {c}") - continue - - # Resolve token - token = None - if "token_env" in c: - token = os.environ.get(c["token_env"]) - if not token: - errors.append(f"{name}: env var {c['token_env']} not set") - continue - elif "token" in c: - token = c["token"] - else: - errors.append(f"{name}: no token or token_env specified") - continue - - # Set cluster - ca_args = [] - if "ca_cert" in c and c["ca_cert"]: - ca_args = ["--certificate-authority", c["ca_cert"]] - else: - ca_args = ["--insecure-skip-tls-verify=true"] - - try: - subprocess.run( - [kube_cmd, "config", "set-cluster", name, - "--server", api_url] + ca_args, - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-cluster failed: {e.stderr.decode().strip()}") - continue - - # Set credentials - try: - subprocess.run( - [kube_cmd, "config", "set-credentials", f"{name}-reporter", - "--token", token], - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-credentials failed: {e.stderr.decode().strip()}") - continue - - # Set context - try: - subprocess.run( - [kube_cmd, "config", "set-context", name, - "--cluster", name, - "--user", f"{name}-reporter"], - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-context failed: {e.stderr.decode().strip()}") - continue - - # Set first successful context as current-context (required by MCP server) - if success == 0: - subprocess.run( - [kube_cmd, "config", "use-context", name], - check=False, capture_output=True, env=env - ) - - success += 1 - -# Verify if requested -verify_results = {} -if verify and success > 0: - print(f"Verifying {success} context(s)...") - for c in clusters: - name = c.get("name", "") - if not name: - continue - try: - subprocess.run( - [kube_cmd, "cluster-info", "--context", name], - capture_output=True, text=True, timeout=15, check=True, env=env - ) - verify_results[name] = "ok" - print(f" {name}: OK") - except subprocess.TimeoutExpired: - verify_results[name] = "timeout" - errors.append(f"{name}: verification timed out") - print(f" {name}: TIMEOUT") - except subprocess.CalledProcessError as e: - verify_results[name] = "failed" - errors.append(f"{name}: verification failed (likely expired token)") - print(f" {name}: FAILED (re-run --setup for this cluster)") - -result = { - "clusters_configured": success, - "clusters_failed": len(errors), - "kubeconfig": output_file, - "errors": errors -} -if verify: - result["verification"] = verify_results - -print("") -print(json.dumps(result, indent=2)) - -if success == 0: - sys.exit(1) -BUILD_SCRIPT + python3 "${SCRIPT_DIR}/build-merged-kubeconfig.py" "$KUBE_CMD" "$CLUSTERS_FILE" "$OUTPUT_FILE" "$VERIFY" echo "" echo "Kubeconfig written to: ${OUTPUT_FILE}" diff --git a/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py new file mode 100644 index 00000000..d88e1c88 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Build a merged kubeconfig from a clusters inventory file. + +Called by build-kubeconfig.sh --build mode. +Requires: python3 (stdlib only), oc or kubectl in PATH. + +Usage: + python3 build-merged-kubeconfig.py + + verify: "True" or "False" +""" + +import json, sys, os, subprocess + +kube_cmd = sys.argv[1] +clusters_file = sys.argv[2] +output_file = sys.argv[3] +verify = sys.argv[4] == "True" + +with open(clusters_file) as f: + config = json.load(f) + +clusters = config.get("clusters", []) +if not clusters: + print('{"error": "No clusters in inventory file"}', file=sys.stderr) + sys.exit(1) + +env = {**os.environ, "KUBECONFIG": output_file} +errors = [] +success = 0 + +for c in clusters: + name = c.get("name", "") + api_url = c.get("api_url", "") + + if not name or not api_url: + errors.append(f"Entry missing name or api_url: {c}") + continue + + # Resolve token + token = None + if "token_env" in c: + token = os.environ.get(c["token_env"]) + if not token: + errors.append(f"{name}: env var {c['token_env']} not set") + continue + elif "token" in c: + token = c["token"] + else: + errors.append(f"{name}: no token or token_env specified") + continue + + # Set cluster + ca_args = [] + if "ca_cert" in c and c["ca_cert"]: + ca_args = ["--certificate-authority", c["ca_cert"]] + else: + ca_args = ["--insecure-skip-tls-verify=true"] + + try: + subprocess.run( + [kube_cmd, "config", "set-cluster", name, + "--server", api_url] + ca_args, + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-cluster failed: {e.stderr.decode().strip()}") + continue + + # Set credentials + try: + subprocess.run( + [kube_cmd, "config", "set-credentials", f"{name}-reporter", + "--token", token], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-credentials failed: {e.stderr.decode().strip()}") + continue + + # Set context + try: + subprocess.run( + [kube_cmd, "config", "set-context", name, + "--cluster", name, + "--user", f"{name}-reporter"], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-context failed: {e.stderr.decode().strip()}") + continue + + # Set first successful context as current-context (required by MCP server) + if success == 0: + subprocess.run( + [kube_cmd, "config", "use-context", name], + check=False, capture_output=True, env=env + ) + + success += 1 + +# Verify if requested +verify_results = {} +if verify and success > 0: + print(f"Verifying {success} context(s)...") + for c in clusters: + name = c.get("name", "") + if not name: + continue + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", name], + capture_output=True, text=True, timeout=15, check=True, env=env + ) + verify_results[name] = "ok" + print(f" {name}: OK") + except subprocess.TimeoutExpired: + verify_results[name] = "timeout" + errors.append(f"{name}: verification timed out") + print(f" {name}: TIMEOUT") + except subprocess.CalledProcessError as e: + verify_results[name] = "failed" + errors.append(f"{name}: verification failed (likely expired token)") + print(f" {name}: FAILED (re-run --setup for this cluster)") + +result = { + "clusters_configured": success, + "clusters_failed": len(errors), + "kubeconfig": output_file, + "errors": errors +} +if verify: + result["verification"] = verify_results + +print("") +print(json.dumps(result, indent=2)) + +if success == 0: + sys.exit(1) diff --git a/ocp-admin/scripts/cluster-report/setup-clusters.py b/ocp-admin/scripts/cluster-report/setup-clusters.py new file mode 100644 index 00000000..277f036f --- /dev/null +++ b/ocp-admin/scripts/cluster-report/setup-clusters.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Apply RBAC and extract SA tokens for cluster-report setup. + +Called by build-kubeconfig.sh --setup mode. +Requires: python3 (stdlib only), oc or kubectl in PATH. + +Usage: + python3 setup-clusters.py \ + [ctx2 ...] +""" + +import subprocess, sys, json, time, os, base64 + +kube_cmd = sys.argv[1] +rbac_manifest = sys.argv[2] +sa_namespace = sys.argv[3] +secret_name = sys.argv[4] +inventory_file = sys.argv[5] +existing_clusters = json.loads(sys.argv[6]) +contexts = sys.argv[7:] + +# Index existing clusters by name for dedup +existing_by_name = {c["name"]: c for c in existing_clusters} + +results = {"setup": [], "errors": []} + +for ctx in contexts: + print(f"--- {ctx} ---") + + # Get server URL for this context + try: + server = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.clusters[?(@.name==\"{ctx}\")].cluster.server}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + + if not server: + # Try matching by context's cluster reference + cluster_ref = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.contexts[?(@.name==\"{ctx}\")].context.cluster}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + if cluster_ref: + server = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f"jsonpath={{.clusters[?(@.name==\"{cluster_ref}\")].cluster.server}}"], + text=True, stderr=subprocess.DEVNULL + ).strip() + except subprocess.CalledProcessError: + results["errors"].append(f"{ctx}: failed to get server URL") + print(f" SKIP: cannot determine server URL") + continue + + if not server: + results["errors"].append(f"{ctx}: no server URL found in kubeconfig") + print(f" SKIP: no server URL") + continue + + # Check connectivity + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", ctx], + capture_output=True, text=True, timeout=15, check=True + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + results["errors"].append(f"{ctx}: cluster unreachable or auth expired") + print(f" SKIP: cluster unreachable (try 'oc login {server}' first)") + continue + + print(f" Server: {server}") + + # Apply RBAC manifest + print(f" Applying RBAC...") + try: + subprocess.run( + [kube_cmd, "apply", "-f", rbac_manifest, "--context", ctx], + capture_output=True, text=True, timeout=30, check=True + ) + except subprocess.CalledProcessError as e: + results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") + print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") + continue + + # Wait for token to be populated (up to 15 seconds) + print(f" Waiting for token...") + token = "" + for attempt in range(15): + try: + token = subprocess.check_output( + [kube_cmd, "get", "secret", secret_name, + "-n", sa_namespace, "--context", ctx, + "-o", "jsonpath={.data.token}"], + text=True, stderr=subprocess.DEVNULL, timeout=10 + ).strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + + if token: + break + time.sleep(1) + + if not token: + results["errors"].append(f"{ctx}: token not populated after 15s") + print(f" FAIL: token Secret not populated") + continue + + # Decode base64 token + try: + decoded_token = base64.b64decode(token).decode("utf-8") + except Exception: + decoded_token = token # might already be decoded + + # Add to inventory + entry = { + "name": ctx, + "api_url": server, + "token": decoded_token + } + existing_by_name[ctx] = entry + results["setup"].append(ctx) + print(f" OK: token extracted") + +# Write inventory +all_clusters = list(existing_by_name.values()) +inventory = {"clusters": all_clusters} + +with open(inventory_file, "w") as f: + json.dump(inventory, f, indent=2) +os.chmod(inventory_file, 0o600) + +print("") +print("=" * 50) +print(f"Setup complete: {len(results['setup'])} succeeded, {len(results['errors'])} failed") +if results["errors"]: + print(f"Errors:") + for e in results["errors"]: + print(f" - {e}") +print(f"Inventory written to: {inventory_file}") +print("") +print("Next step:") +print(f" bash build-kubeconfig.sh --build --clusters {inventory_file} --verify") + +# Output JSON summary to stderr for scripting +json.dump(results, sys.stderr, indent=2) From 87e990f194ed0323dfdffe50583f613f91fa8f5f Mon Sep 17 00:00:00 2001 From: ikrispin Date: Sun, 8 Mar 2026 15:29:02 +0200 Subject: [PATCH 06/11] refactor: consolidate scripts into a single python script --- ocp-admin/README.md | 8 +- ocp-admin/docs/multi-cluster-auth.md | 28 +- .../cluster-report/build-kubeconfig.py | 405 ++++++++++++++++++ .../cluster-report/build-kubeconfig.sh | 179 -------- .../cluster-report/build-merged-kubeconfig.py | 139 ------ .../scripts/cluster-report/setup-clusters.py | 146 ------- ocp-admin/skills/cluster-report/SKILL.md | 4 +- 7 files changed, 423 insertions(+), 486 deletions(-) create mode 100644 ocp-admin/scripts/cluster-report/build-kubeconfig.py delete mode 100755 ocp-admin/scripts/cluster-report/build-kubeconfig.sh delete mode 100644 ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py delete mode 100644 ocp-admin/scripts/cluster-report/setup-clusters.py diff --git a/ocp-admin/README.md b/ocp-admin/README.md index 7c0a739c..f30aa8cf 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -22,7 +22,7 @@ For running `cluster-report` across many clusters (10–100+), use service accou | Script / Manifest | Purpose | |-------------------|---------| -| [`build-kubeconfig.sh`](scripts/cluster-report/build-kubeconfig.sh) | Builds a merged kubeconfig from SA tokens (`--setup` + `--build` modes) | +| [`build-kubeconfig.py`](scripts/cluster-report/build-kubeconfig.py) | Builds a merged kubeconfig from SA tokens (`setup` + `build` subcommands) | | [`cluster-reporter-rbac.yaml`](scripts/cluster-report/cluster-reporter-rbac.yaml) | Read-only RBAC resources applied once per cluster | > **Required permissions**: The RBAC setup creates cluster-scoped resources (ClusterRole, ClusterRoleBinding), so the user running `--setup` needs `cluster-admin` privileges. This is a one-time step per cluster. @@ -31,11 +31,11 @@ For running `cluster-report` across many clusters (10–100+), use service accou ```bash # 1. One-time (requires cluster-admin): apply RBAC and extract tokens for all clusters you're logged into -bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh --setup --all-contexts +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py setup --all-contexts # 2. Build merged kubeconfig from saved tokens -bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ - --build --clusters ~/.ocp-clusters/clusters.json --verify +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py \ + build --clusters ~/.ocp-clusters/clusters.json --verify # 3. Export and run export KUBECONFIG=/tmp/cluster-report-kubeconfig diff --git a/ocp-admin/docs/multi-cluster-auth.md b/ocp-admin/docs/multi-cluster-auth.md index 7840e676..e187471b 100644 --- a/ocp-admin/docs/multi-cluster-auth.md +++ b/ocp-admin/docs/multi-cluster-auth.md @@ -20,15 +20,15 @@ If you're currently logged into all the clusters you would like to get a report ```bash # Step 1: Setup — applies RBAC to each cluster, extracts SA tokens -bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh --setup --all-contexts +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py setup --all-contexts # Step 2: Build — assembles a merged kubeconfig from the inventory -bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ - --build --clusters ~/.ocp-clusters/clusters.json --verify +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py \ + build --clusters ~/.ocp-clusters/clusters.json --verify # Step 3: Use — export and run the skill export KUBECONFIG=/tmp/cluster-report-kubeconfig -# Then in Claude Code: /cluster-report +# Then in Claude Code use the skill: /cluster-report ``` After the one-time setup, only Steps 2–3 are needed for future report sessions. @@ -85,8 +85,8 @@ Set permissions: `chmod 600 ~/.ocp-clusters/clusters.json` ### 4. Build Kubeconfig ```bash -bash ocp-admin/scripts/cluster-report/build-kubeconfig.sh \ - --build --clusters ~/.ocp-clusters/clusters.json --output ~/.kube/cluster-report-kubeconfig +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py \ + build --clusters ~/.ocp-clusters/clusters.json --output ~/.kube/cluster-report-kubeconfig ``` ## RBAC Permissions @@ -159,10 +159,10 @@ If `ca_cert` is omitted, TLS verification is skipped (`--insecure-skip-tls-verif ## Script Reference -### `--setup` Mode +### `setup` Subcommand ```bash -bash build-kubeconfig.sh --setup [OPTIONS] +python3 build-kubeconfig.py setup [OPTIONS] ``` @@ -181,10 +181,10 @@ Behavior: - Skips unreachable clusters with an error message - Appends to existing inventory (deduplicates by name) -### `--build` Mode +### `build` Subcommand ```bash -bash build-kubeconfig.sh --build --clusters [OPTIONS] +python3 build-kubeconfig.py build --clusters [OPTIONS] ``` @@ -208,23 +208,19 @@ Behavior: SA token Secrets do not expire, but you may want to rotate them periodically: ```bash -# On each cluster that needs rotation: oc delete secret cluster-reporter-token -n cluster-reporter-system oc apply -f ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml -# Extract the new token: oc get secret cluster-reporter-token -n cluster-reporter-system \ -o jsonpath='{.data.token}' | base64 -d -# Update clusters.json with the new token, then rebuild: -bash build-kubeconfig.sh --build --clusters ~/.ocp-clusters/clusters.json --verify +python3 build-kubeconfig.py build --clusters ~/.ocp-clusters/clusters.json --verify ``` To detect expired or invalid tokens: ```bash -bash build-kubeconfig.sh --build --clusters ~/.ocp-clusters/clusters.json --verify -# Failed verifications indicate clusters needing token rotation +python3 build-kubeconfig.py build --clusters ~/.ocp-clusters/clusters.json --verify ``` ## Security Best Practices diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-kubeconfig.py new file mode 100644 index 00000000..602ff4b5 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.py @@ -0,0 +1,405 @@ +#!/usr/bin/env python3 +"""Multi-cluster kubeconfig builder for cluster-report. + +Two subcommands: + setup Apply RBAC and extract SA tokens for clusters you're logged into + build Build a merged kubeconfig from a clusters inventory file + +Usage: + python3 build-kubeconfig.py setup [--all-contexts] [--contexts ctx1,ctx2] + [--output-inventory ] + + python3 build-kubeconfig.py build --clusters + [--output ] [--verify] + +Requires: oc or kubectl, python3 (stdlib only) +""" + +import argparse +import base64 +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +RBAC_MANIFEST = SCRIPT_DIR / "cluster-reporter-rbac.yaml" + +SA_NAMESPACE = "cluster-reporter-system" +SECRET_NAME = "cluster-reporter-token" + +DEFAULT_INVENTORY = Path.home() / ".ocp-clusters" / "clusters.json" +DEFAULT_OUTPUT = Path("/tmp/cluster-report-kubeconfig") + + +def find_kube_cmd(): + """Detect oc or kubectl in PATH.""" + for cmd in ("oc", "kubectl"): + if shutil.which(cmd): + return cmd + print('{"error": "Neither oc nor kubectl found in PATH"}', file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Setup mode +# --------------------------------------------------------------------------- + +def run_setup(args): + kube_cmd = find_kube_cmd() + inventory_file = Path(args.output_inventory) + + if not RBAC_MANIFEST.is_file(): + print(f"Error: RBAC manifest not found at {RBAC_MANIFEST}", file=sys.stderr) + sys.exit(1) + + try: + all_ctx = subprocess.check_output( + [kube_cmd, "config", "get-contexts", "-o", "name"], + text=True, stderr=subprocess.DEVNULL + ).strip().splitlines() + except subprocess.CalledProcessError: + all_ctx = [] + + if not all_ctx: + print('{"error": "No kubeconfig contexts found. Log in to at least one cluster first."}', + file=sys.stderr) + sys.exit(1) + + if args.contexts: + contexts = args.contexts.split(",") + elif args.all_contexts: + contexts = all_ctx + else: + print("Available contexts:") + for i, ctx in enumerate(all_ctx, 1): + print(f" {i}. {ctx}") + print() + print("Run with --all-contexts to setup all, or --contexts ctx1,ctx2 to select specific ones.") + sys.exit(0) + + print(f"Setting up {len(contexts)} cluster(s)...\n") + + inventory_file.parent.mkdir(parents=True, exist_ok=True) + + existing_by_name = {} + if inventory_file.is_file(): + try: + with open(inventory_file) as f: + existing_by_name = {c["name"]: c for c in json.load(f).get("clusters", [])} + except (json.JSONDecodeError, KeyError): + pass + + results = {"setup": [], "errors": []} + + for ctx in contexts: + print(f"--- {ctx} ---") + + server = _get_server_url(kube_cmd, ctx) + if not server: + results["errors"].append(f"{ctx}: no server URL found in kubeconfig") + print(" SKIP: no server URL") + continue + + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", ctx], + capture_output=True, text=True, timeout=15, check=True + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + results["errors"].append(f"{ctx}: cluster unreachable or auth expired") + print(f" SKIP: cluster unreachable (try '{kube_cmd} login {server}' first)") + continue + + print(f" Server: {server}") + + print(" Applying RBAC...") + try: + subprocess.run( + [kube_cmd, "apply", "-f", str(RBAC_MANIFEST), "--context", ctx], + capture_output=True, text=True, timeout=30, check=True + ) + except subprocess.CalledProcessError as e: + results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") + print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") + continue + + print(" Waiting for token...") + token = _wait_for_token(kube_cmd, ctx) + if not token: + results["errors"].append(f"{ctx}: token not populated after 15s") + print(" FAIL: token Secret not populated") + continue + + try: + decoded_token = base64.b64decode(token).decode("utf-8") + except Exception: + decoded_token = token + + existing_by_name[ctx] = {"name": ctx, "api_url": server, "token": decoded_token} + results["setup"].append(ctx) + print(" OK: token extracted") + + with open(inventory_file, "w") as f: + json.dump({"clusters": list(existing_by_name.values())}, f, indent=2) + os.chmod(inventory_file, 0o600) + + print() + print("=" * 50) + print(f"Setup complete: {len(results['setup'])} succeeded, {len(results['errors'])} failed") + if results["errors"]: + print("Errors:") + for e in results["errors"]: + print(f" - {e}") + print(f"Inventory written to: {inventory_file}") + print() + print("Next step:") + print(f" python3 {__file__} build --clusters {inventory_file} --verify") + + json.dump(results, sys.stderr, indent=2) + + +def _get_server_url(kube_cmd, ctx): + """Resolve the API server URL for a kubeconfig context.""" + try: + server = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f'jsonpath={{.clusters[?(@.name=="{ctx}")].cluster.server}}'], + text=True, stderr=subprocess.DEVNULL + ).strip() + if server: + return server + + cluster_ref = subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f'jsonpath={{.contexts[?(@.name=="{ctx}")].context.cluster}}'], + text=True, stderr=subprocess.DEVNULL + ).strip() + if cluster_ref: + return subprocess.check_output( + [kube_cmd, "config", "view", "-o", + f'jsonpath={{.clusters[?(@.name=="{cluster_ref}")].cluster.server}}'], + text=True, stderr=subprocess.DEVNULL + ).strip() or None + except subprocess.CalledProcessError: + pass + return None + + +def _wait_for_token(kube_cmd, ctx, timeout_secs=15): + """Poll for the SA token Secret to be populated.""" + for _ in range(timeout_secs): + try: + token = subprocess.check_output( + [kube_cmd, "get", "secret", SECRET_NAME, + "-n", SA_NAMESPACE, "--context", ctx, + "-o", "jsonpath={.data.token}"], + text=True, stderr=subprocess.DEVNULL, timeout=10 + ).strip() + if token: + return token + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + time.sleep(1) + return None + + +# --------------------------------------------------------------------------- +# Build mode +# --------------------------------------------------------------------------- + +def run_build(args): + kube_cmd = find_kube_cmd() + clusters_file = Path(args.clusters) + output_file = Path(args.output) + + if not clusters_file.is_file(): + print(f'{{"error": "Clusters file not found: {clusters_file}"}}', file=sys.stderr) + sys.exit(1) + + with open(clusters_file) as f: + config = json.load(f) + + clusters = config.get("clusters", []) + if not clusters: + print('{"error": "No clusters in inventory file"}', file=sys.stderr) + sys.exit(1) + + output_file.unlink(missing_ok=True) + output_file.touch(mode=0o600) + + env = {**os.environ, "KUBECONFIG": str(output_file)} + errors = [] + success = 0 + + for c in clusters: + name = c.get("name", "") + api_url = c.get("api_url", "") + + if not name or not api_url: + errors.append(f"Entry missing name or api_url: {c}") + continue + + token = _resolve_token(c, errors) + if token is None: + continue + + ca_args = (["--certificate-authority", c["ca_cert"]] + if c.get("ca_cert") + else ["--insecure-skip-tls-verify=true"]) + try: + subprocess.run( + [kube_cmd, "config", "set-cluster", name, "--server", api_url] + ca_args, + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-cluster failed: {e.stderr.decode().strip()}") + continue + + try: + subprocess.run( + [kube_cmd, "config", "set-credentials", f"{name}-reporter", "--token", token], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-credentials failed: {e.stderr.decode().strip()}") + continue + + try: + subprocess.run( + [kube_cmd, "config", "set-context", name, + "--cluster", name, "--user", f"{name}-reporter"], + check=True, capture_output=True, env=env + ) + except subprocess.CalledProcessError as e: + errors.append(f"{name}: set-context failed: {e.stderr.decode().strip()}") + continue + + if success == 0: + subprocess.run( + [kube_cmd, "config", "use-context", name], + check=False, capture_output=True, env=env + ) + + success += 1 + + verify_results = {} + if args.verify and success > 0: + print(f"Verifying {success} context(s)...") + for c in clusters: + name = c.get("name", "") + if not name: + continue + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", name], + capture_output=True, text=True, timeout=15, check=True, env=env + ) + verify_results[name] = "ok" + print(f" {name}: OK") + except subprocess.TimeoutExpired: + verify_results[name] = "timeout" + errors.append(f"{name}: verification timed out") + print(f" {name}: TIMEOUT") + except subprocess.CalledProcessError: + verify_results[name] = "failed" + errors.append(f"{name}: verification failed (likely expired token)") + print(f" {name}: FAILED (re-run setup for this cluster)") + + result = { + "clusters_configured": success, + "clusters_failed": len(errors), + "kubeconfig": str(output_file), + "errors": errors, + } + if args.verify: + result["verification"] = verify_results + + print() + print(json.dumps(result, indent=2)) + print() + print(f"Kubeconfig written to: {output_file}") + print() + print("To use with cluster-report:") + print(f" export KUBECONFIG={output_file}") + + if success == 0: + sys.exit(1) + + +def _resolve_token(cluster_entry, errors): + """Resolve token from inline value or environment variable. Returns None on failure.""" + name = cluster_entry.get("name", "") + if "token_env" in cluster_entry: + token = os.environ.get(cluster_entry["token_env"]) + if not token: + errors.append(f"{name}: env var {cluster_entry['token_env']} not set") + return None + return token + if "token" in cluster_entry: + return cluster_entry["token"] + errors.append(f"{name}: no token or token_env specified") + return None + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Multi-cluster kubeconfig builder for cluster-report", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + # -- setup -- + setup_parser = subparsers.add_parser( + "setup", + help="Apply RBAC to clusters you're logged into, extract SA tokens, " + "and write a clusters inventory file.", + ) + setup_parser.add_argument( + "--all-contexts", action="store_true", + help="Setup all kubeconfig contexts without prompting.", + ) + setup_parser.add_argument( + "--contexts", type=str, default=None, + help="Comma-separated list of contexts to setup.", + ) + setup_parser.add_argument( + "--output-inventory", type=str, default=str(DEFAULT_INVENTORY), + help=f"Path for the clusters inventory file (default: {DEFAULT_INVENTORY}).", + ) + + # -- build -- + build_parser = subparsers.add_parser( + "build", + help="Read a clusters inventory file and build a merged kubeconfig.", + ) + build_parser.add_argument( + "--clusters", type=str, required=True, + help="Path to the clusters inventory JSON file.", + ) + build_parser.add_argument( + "--output", type=str, default=str(DEFAULT_OUTPUT), + help=f"Path for the generated kubeconfig (default: {DEFAULT_OUTPUT}).", + ) + build_parser.add_argument( + "--verify", action="store_true", + help="Test each context after building the kubeconfig.", + ) + + args = parser.parse_args() + + if args.command == "setup": + run_setup(args) + elif args.command == "build": + run_build(args) + + +if __name__ == "__main__": + main() diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.sh b/ocp-admin/scripts/cluster-report/build-kubeconfig.sh deleted file mode 100755 index 0bc3bd3f..00000000 --- a/ocp-admin/scripts/cluster-report/build-kubeconfig.sh +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env bash -# build-kubeconfig.sh — Multi-cluster kubeconfig builder for cluster-report -# -# Two modes: -# --setup Apply RBAC and extract SA tokens for clusters you're logged into -# --build Build a merged kubeconfig from a clusters inventory file -# -# Usage: -# bash build-kubeconfig.sh --setup [--all-contexts] [--contexts ctx1,ctx2] -# [--output-inventory ] -# -# bash build-kubeconfig.sh --build --clusters -# [--output ] [--verify] -# -# Requires: oc or kubectl, python3 (stdlib only) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RBAC_MANIFEST="${SCRIPT_DIR}/cluster-reporter-rbac.yaml" - -MODE="" -CLUSTERS_FILE="" -OUTPUT_FILE="/tmp/cluster-report-kubeconfig" -INVENTORY_FILE="${HOME}/.ocp-clusters/clusters.json" -VERIFY=false -ALL_CONTEXTS=false -SELECTED_CONTEXTS="" - -# --- Argument parsing --- - -usage() { - cat <<'USAGE' -Usage: - build-kubeconfig.sh --setup [--all-contexts] [--contexts ctx1,ctx2] - [--output-inventory ] - - build-kubeconfig.sh --build --clusters - [--output ] [--verify] - -Modes: - --setup Apply RBAC to clusters you're logged into, extract SA tokens, - and write a clusters inventory file. - --build Read a clusters inventory file and build a merged kubeconfig. - -Setup options: - --all-contexts Setup all kubeconfig contexts without prompting. - --contexts c1,c2 Setup only the specified contexts (comma-separated). - --output-inventory Path for the clusters inventory file. - Default: ~/.ocp-clusters/clusters.json - -Build options: - --clusters Path to the clusters inventory JSON file (required). - --output Path for the generated kubeconfig. - Default: /tmp/cluster-report-kubeconfig - --verify Test each context after building the kubeconfig. -USAGE - exit 1 -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --setup) MODE="setup"; shift ;; - --build) MODE="build"; shift ;; - --clusters) CLUSTERS_FILE="$2"; shift 2 ;; - --output) OUTPUT_FILE="$2"; shift 2 ;; - --output-inventory) INVENTORY_FILE="$2"; shift 2 ;; - --verify) VERIFY=true; shift ;; - --all-contexts) ALL_CONTEXTS=true; shift ;; - --contexts) SELECTED_CONTEXTS="$2"; shift 2 ;; - -h|--help) usage ;; - *) echo "Unknown flag: $1" >&2; usage ;; - esac -done - -if [[ -z "$MODE" ]]; then - echo "Error: specify --setup or --build" >&2 - usage -fi - -# --- Helpers --- - -KUBE_CMD="" -if command -v oc &>/dev/null; then - KUBE_CMD="oc" -elif command -v kubectl &>/dev/null; then - KUBE_CMD="kubectl" -else - echo '{"error": "Neither oc nor kubectl found in PATH"}' >&2 - exit 1 -fi - -SA_NAMESPACE="cluster-reporter-system" -SA_NAME="cluster-reporter" -SECRET_NAME="cluster-reporter-token" - -# --- Setup mode --- - -run_setup() { - if [[ ! -f "$RBAC_MANIFEST" ]]; then - echo "Error: RBAC manifest not found at ${RBAC_MANIFEST}" >&2 - exit 1 - fi - - mapfile -t ALL_CTX < <($KUBE_CMD config get-contexts -o name 2>/dev/null) - - if [[ ${#ALL_CTX[@]} -eq 0 ]]; then - echo '{"error": "No kubeconfig contexts found. Log in to at least one cluster first."}' >&2 - exit 1 - fi - - CONTEXTS_TO_PROCESS=() - - if [[ -n "$SELECTED_CONTEXTS" ]]; then - IFS=',' read -ra CONTEXTS_TO_PROCESS <<< "$SELECTED_CONTEXTS" - elif [[ "$ALL_CONTEXTS" == "true" ]]; then - CONTEXTS_TO_PROCESS=("${ALL_CTX[@]}") - else - echo "Available contexts:" - for i in "${!ALL_CTX[@]}"; do - echo " $((i+1)). ${ALL_CTX[$i]}" - done - echo "" - echo "Run with --all-contexts to setup all, or --contexts ctx1,ctx2 to select specific ones." - exit 0 - fi - - echo "Setting up ${#CONTEXTS_TO_PROCESS[@]} cluster(s)..." - echo "" - - INVENTORY_DIR="$(dirname "$INVENTORY_FILE")" - mkdir -p "$INVENTORY_DIR" - - EXISTING_CLUSTERS="[]" - if [[ -f "$INVENTORY_FILE" ]]; then - EXISTING_CLUSTERS=$(python3 -c " -import json, sys -with open('${INVENTORY_FILE}') as f: - data = json.load(f) -json.dump(data.get('clusters', []), sys.stdout) -" 2>/dev/null || echo "[]") - fi - - python3 "${SCRIPT_DIR}/setup-clusters.py" "$KUBE_CMD" "$RBAC_MANIFEST" "$SA_NAMESPACE" "$SECRET_NAME" "$INVENTORY_FILE" "$EXISTING_CLUSTERS" "${CONTEXTS_TO_PROCESS[@]}" -} - -# --- Build mode --- - -run_build() { - if [[ -z "$CLUSTERS_FILE" ]]; then - echo "Error: --clusters is required for --build mode" >&2 - usage - fi - - if [[ ! -f "$CLUSTERS_FILE" ]]; then - echo "{\"error\": \"Clusters file not found: ${CLUSTERS_FILE}\"}" >&2 - exit 1 - fi - - rm -f "$OUTPUT_FILE" - touch "$OUTPUT_FILE" - chmod 600 "$OUTPUT_FILE" - - python3 "${SCRIPT_DIR}/build-merged-kubeconfig.py" "$KUBE_CMD" "$CLUSTERS_FILE" "$OUTPUT_FILE" "$VERIFY" - - echo "" - echo "Kubeconfig written to: ${OUTPUT_FILE}" - echo "" - echo "To use with cluster-report:" - echo " export KUBECONFIG=${OUTPUT_FILE}" -} - -# --- Main --- - -case "$MODE" in - setup) run_setup ;; - build) run_build ;; - *) usage ;; -esac diff --git a/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py deleted file mode 100644 index d88e1c88..00000000 --- a/ocp-admin/scripts/cluster-report/build-merged-kubeconfig.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""Build a merged kubeconfig from a clusters inventory file. - -Called by build-kubeconfig.sh --build mode. -Requires: python3 (stdlib only), oc or kubectl in PATH. - -Usage: - python3 build-merged-kubeconfig.py - - verify: "True" or "False" -""" - -import json, sys, os, subprocess - -kube_cmd = sys.argv[1] -clusters_file = sys.argv[2] -output_file = sys.argv[3] -verify = sys.argv[4] == "True" - -with open(clusters_file) as f: - config = json.load(f) - -clusters = config.get("clusters", []) -if not clusters: - print('{"error": "No clusters in inventory file"}', file=sys.stderr) - sys.exit(1) - -env = {**os.environ, "KUBECONFIG": output_file} -errors = [] -success = 0 - -for c in clusters: - name = c.get("name", "") - api_url = c.get("api_url", "") - - if not name or not api_url: - errors.append(f"Entry missing name or api_url: {c}") - continue - - # Resolve token - token = None - if "token_env" in c: - token = os.environ.get(c["token_env"]) - if not token: - errors.append(f"{name}: env var {c['token_env']} not set") - continue - elif "token" in c: - token = c["token"] - else: - errors.append(f"{name}: no token or token_env specified") - continue - - # Set cluster - ca_args = [] - if "ca_cert" in c and c["ca_cert"]: - ca_args = ["--certificate-authority", c["ca_cert"]] - else: - ca_args = ["--insecure-skip-tls-verify=true"] - - try: - subprocess.run( - [kube_cmd, "config", "set-cluster", name, - "--server", api_url] + ca_args, - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-cluster failed: {e.stderr.decode().strip()}") - continue - - # Set credentials - try: - subprocess.run( - [kube_cmd, "config", "set-credentials", f"{name}-reporter", - "--token", token], - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-credentials failed: {e.stderr.decode().strip()}") - continue - - # Set context - try: - subprocess.run( - [kube_cmd, "config", "set-context", name, - "--cluster", name, - "--user", f"{name}-reporter"], - check=True, capture_output=True, env=env - ) - except subprocess.CalledProcessError as e: - errors.append(f"{name}: set-context failed: {e.stderr.decode().strip()}") - continue - - # Set first successful context as current-context (required by MCP server) - if success == 0: - subprocess.run( - [kube_cmd, "config", "use-context", name], - check=False, capture_output=True, env=env - ) - - success += 1 - -# Verify if requested -verify_results = {} -if verify and success > 0: - print(f"Verifying {success} context(s)...") - for c in clusters: - name = c.get("name", "") - if not name: - continue - try: - subprocess.run( - [kube_cmd, "cluster-info", "--context", name], - capture_output=True, text=True, timeout=15, check=True, env=env - ) - verify_results[name] = "ok" - print(f" {name}: OK") - except subprocess.TimeoutExpired: - verify_results[name] = "timeout" - errors.append(f"{name}: verification timed out") - print(f" {name}: TIMEOUT") - except subprocess.CalledProcessError as e: - verify_results[name] = "failed" - errors.append(f"{name}: verification failed (likely expired token)") - print(f" {name}: FAILED (re-run --setup for this cluster)") - -result = { - "clusters_configured": success, - "clusters_failed": len(errors), - "kubeconfig": output_file, - "errors": errors -} -if verify: - result["verification"] = verify_results - -print("") -print(json.dumps(result, indent=2)) - -if success == 0: - sys.exit(1) diff --git a/ocp-admin/scripts/cluster-report/setup-clusters.py b/ocp-admin/scripts/cluster-report/setup-clusters.py deleted file mode 100644 index 277f036f..00000000 --- a/ocp-admin/scripts/cluster-report/setup-clusters.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""Apply RBAC and extract SA tokens for cluster-report setup. - -Called by build-kubeconfig.sh --setup mode. -Requires: python3 (stdlib only), oc or kubectl in PATH. - -Usage: - python3 setup-clusters.py \ - [ctx2 ...] -""" - -import subprocess, sys, json, time, os, base64 - -kube_cmd = sys.argv[1] -rbac_manifest = sys.argv[2] -sa_namespace = sys.argv[3] -secret_name = sys.argv[4] -inventory_file = sys.argv[5] -existing_clusters = json.loads(sys.argv[6]) -contexts = sys.argv[7:] - -# Index existing clusters by name for dedup -existing_by_name = {c["name"]: c for c in existing_clusters} - -results = {"setup": [], "errors": []} - -for ctx in contexts: - print(f"--- {ctx} ---") - - # Get server URL for this context - try: - server = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.clusters[?(@.name==\"{ctx}\")].cluster.server}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - - if not server: - # Try matching by context's cluster reference - cluster_ref = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.contexts[?(@.name==\"{ctx}\")].context.cluster}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - if cluster_ref: - server = subprocess.check_output( - [kube_cmd, "config", "view", "-o", - f"jsonpath={{.clusters[?(@.name==\"{cluster_ref}\")].cluster.server}}"], - text=True, stderr=subprocess.DEVNULL - ).strip() - except subprocess.CalledProcessError: - results["errors"].append(f"{ctx}: failed to get server URL") - print(f" SKIP: cannot determine server URL") - continue - - if not server: - results["errors"].append(f"{ctx}: no server URL found in kubeconfig") - print(f" SKIP: no server URL") - continue - - # Check connectivity - try: - subprocess.run( - [kube_cmd, "cluster-info", "--context", ctx], - capture_output=True, text=True, timeout=15, check=True - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - results["errors"].append(f"{ctx}: cluster unreachable or auth expired") - print(f" SKIP: cluster unreachable (try 'oc login {server}' first)") - continue - - print(f" Server: {server}") - - # Apply RBAC manifest - print(f" Applying RBAC...") - try: - subprocess.run( - [kube_cmd, "apply", "-f", rbac_manifest, "--context", ctx], - capture_output=True, text=True, timeout=30, check=True - ) - except subprocess.CalledProcessError as e: - results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") - print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") - continue - - # Wait for token to be populated (up to 15 seconds) - print(f" Waiting for token...") - token = "" - for attempt in range(15): - try: - token = subprocess.check_output( - [kube_cmd, "get", "secret", secret_name, - "-n", sa_namespace, "--context", ctx, - "-o", "jsonpath={.data.token}"], - text=True, stderr=subprocess.DEVNULL, timeout=10 - ).strip() - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - pass - - if token: - break - time.sleep(1) - - if not token: - results["errors"].append(f"{ctx}: token not populated after 15s") - print(f" FAIL: token Secret not populated") - continue - - # Decode base64 token - try: - decoded_token = base64.b64decode(token).decode("utf-8") - except Exception: - decoded_token = token # might already be decoded - - # Add to inventory - entry = { - "name": ctx, - "api_url": server, - "token": decoded_token - } - existing_by_name[ctx] = entry - results["setup"].append(ctx) - print(f" OK: token extracted") - -# Write inventory -all_clusters = list(existing_by_name.values()) -inventory = {"clusters": all_clusters} - -with open(inventory_file, "w") as f: - json.dump(inventory, f, indent=2) -os.chmod(inventory_file, 0o600) - -print("") -print("=" * 50) -print(f"Setup complete: {len(results['setup'])} succeeded, {len(results['errors'])} failed") -if results["errors"]: - print(f"Errors:") - for e in results["errors"]: - print(f" - {e}") -print(f"Inventory written to: {inventory_file}") -print("") -print("Next step:") -print(f" bash build-kubeconfig.sh --build --clusters {inventory_file} --verify") - -# Output JSON summary to stderr for scripting -json.dump(results, sys.stderr, indent=2) diff --git a/ocp-admin/skills/cluster-report/SKILL.md b/ocp-admin/skills/cluster-report/SKILL.md index 6412d948..545c565c 100644 --- a/ocp-admin/skills/cluster-report/SKILL.md +++ b/ocp-admin/skills/cluster-report/SKILL.md @@ -38,7 +38,7 @@ Generate a unified health and resource report across multiple OpenShift/Kubernet **Required Environment Variables**: `KUBECONFIG` — must contain at least one cluster context. Two or more recommended for comparison. -**Multi-Cluster Setup**: For large-scale deployments using service account tokens instead of interactive `oc login`, see [multi-cluster-auth.md](../../docs/multi-cluster-auth.md) and the [build-kubeconfig.sh](../../scripts/cluster-report/build-kubeconfig.sh) helper script. +**Multi-Cluster Setup**: For large-scale deployments using service account tokens instead of interactive `oc login`, see [multi-cluster-auth.md](../../docs/multi-cluster-auth.md) and the [build-kubeconfig.py](../../scripts/cluster-report/build-kubeconfig.py) helper script. **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 @@ -347,7 +347,7 @@ Would you like to: | 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: re-run `build-kubeconfig.sh --build --verify` or `oc login ` | +| Auth expired (401) | Skip cluster, suggest: re-run `build-kubeconfig.py build --verify` or `oc login ` | | No GPUs found | Display 0 (not an error) | | Empty cluster | Report with all zeros (valid data) | From 3565def99ef017b28e350acf029c1988627e6ed4 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Mon, 9 Mar 2026 13:12:40 +0200 Subject: [PATCH 07/11] Rename plugin from 'OpenShift Administration Agentic Skills Collection' to 'ocp-admin' --- ocp-admin/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ocp-admin/.claude-plugin/plugin.json b/ocp-admin/.claude-plugin/plugin.json index d0931c07..0c57acf1 100644 --- a/ocp-admin/.claude-plugin/plugin.json +++ b/ocp-admin/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { - "name": "OpenShift Administration Agentic Skills Collection", + "name": "ocp-admin", "version": "1.0.0", "description": "Automation capabilities for OpenShift Container Platform cluster management, workload orchestration, security policies, and operational tasks.", "author": { From 634969e4afe3186ed199084c129a0ff247b7f1e3 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Mon, 9 Mar 2026 14:39:14 +0200 Subject: [PATCH 08/11] add skip-rbac flag for when it's already applied for extracting existing SA token and fix plugin name --- ocp-admin/.claude-plugin/plugin.json | 2 +- ocp-admin/README.md | 5 +++- .../cluster-report/build-kubeconfig.py | 29 ++++++++++++------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ocp-admin/.claude-plugin/plugin.json b/ocp-admin/.claude-plugin/plugin.json index d0931c07..0c57acf1 100644 --- a/ocp-admin/.claude-plugin/plugin.json +++ b/ocp-admin/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { - "name": "OpenShift Administration Agentic Skills Collection", + "name": "ocp-admin", "version": "1.0.0", "description": "Automation capabilities for OpenShift Container Platform cluster management, workload orchestration, security policies, and operational tasks.", "author": { diff --git a/ocp-admin/README.md b/ocp-admin/README.md index f30aa8cf..8c28acc9 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -25,7 +25,7 @@ For running `cluster-report` across many clusters (10–100+), use service accou | [`build-kubeconfig.py`](scripts/cluster-report/build-kubeconfig.py) | Builds a merged kubeconfig from SA tokens (`setup` + `build` subcommands) | | [`cluster-reporter-rbac.yaml`](scripts/cluster-report/cluster-reporter-rbac.yaml) | Read-only RBAC resources applied once per cluster | -> **Required permissions**: The RBAC setup creates cluster-scoped resources (ClusterRole, ClusterRoleBinding), so the user running `--setup` needs `cluster-admin` privileges. This is a one-time step per cluster. +> **Required permissions**: The RBAC setup creates cluster-scoped resources (ClusterRole, ClusterRoleBinding), so the user running `setup` needs `cluster-admin` privileges. This is a one-time step per cluster. If RBAC has already been applied, use `--skip-rbac` to skip the apply step and only extract existing SA tokens. **Quick start:** @@ -33,6 +33,9 @@ For running `cluster-report` across many clusters (10–100+), use service accou # 1. One-time (requires cluster-admin): apply RBAC and extract tokens for all clusters you're logged into python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py setup --all-contexts +# If RBAC is already configured, skip the apply step and only extract tokens +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py setup --all-contexts --skip-rbac + # 2. Build merged kubeconfig from saved tokens python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py \ build --clusters ~/.ocp-clusters/clusters.json --verify diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-kubeconfig.py index 602ff4b5..1a7fe337 100644 --- a/ocp-admin/scripts/cluster-report/build-kubeconfig.py +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.py @@ -116,16 +116,19 @@ def run_setup(args): print(f" Server: {server}") - print(" Applying RBAC...") - try: - subprocess.run( - [kube_cmd, "apply", "-f", str(RBAC_MANIFEST), "--context", ctx], - capture_output=True, text=True, timeout=30, check=True - ) - except subprocess.CalledProcessError as e: - results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") - print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") - continue + if args.skip_rbac: + print(" Skipping RBAC apply (--skip-rbac)") + else: + print(" Applying RBAC...") + try: + subprocess.run( + [kube_cmd, "apply", "-f", str(RBAC_MANIFEST), "--context", ctx], + capture_output=True, text=True, timeout=30, check=True + ) + except subprocess.CalledProcessError as e: + results["errors"].append(f"{ctx}: RBAC apply failed: {e.stderr.strip()}") + print(f" FAIL: RBAC apply failed: {e.stderr.strip()}") + continue print(" Waiting for token...") token = _wait_for_token(kube_cmd, ctx) @@ -295,7 +298,7 @@ def run_build(args): continue try: subprocess.run( - [kube_cmd, "cluster-info", "--context", name], + [kube_cmd, "get", "nodes", "--context", name, "-o", "name", "--no-headers"], capture_output=True, text=True, timeout=15, check=True, env=env ) verify_results[name] = "ok" @@ -370,6 +373,10 @@ def main(): "--contexts", type=str, default=None, help="Comma-separated list of contexts to setup.", ) + setup_parser.add_argument( + "--skip-rbac", action="store_true", + help="Skip RBAC apply and only extract tokens (use when RBAC is already configured).", + ) setup_parser.add_argument( "--output-inventory", type=str, default=str(DEFAULT_INVENTORY), help=f"Path for the clusters inventory file (default: {DEFAULT_INVENTORY}).", From e791d36bddf464f93ed565173a17f655b0af9ab7 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Mon, 9 Mar 2026 16:04:05 +0200 Subject: [PATCH 09/11] add pre-flight checks for fail-fast in case of missing prerequisites --- .../cluster-report/build-kubeconfig.py | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-kubeconfig.py index 1a7fe337..5d72f2a5 100644 --- a/ocp-admin/scripts/cluster-report/build-kubeconfig.py +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.py @@ -52,7 +52,7 @@ def run_setup(args): kube_cmd = find_kube_cmd() inventory_file = Path(args.output_inventory) - if not RBAC_MANIFEST.is_file(): + if not args.skip_rbac and not RBAC_MANIFEST.is_file(): print(f"Error: RBAC manifest not found at {RBAC_MANIFEST}", file=sys.stderr) sys.exit(1) @@ -71,6 +71,11 @@ def run_setup(args): if args.contexts: contexts = args.contexts.split(",") + unknown = [c for c in contexts if c not in all_ctx] + if unknown: + print(f"Error: unknown context(s): {', '.join(unknown)}", file=sys.stderr) + print(f"Available: {', '.join(all_ctx)}", file=sys.stderr) + sys.exit(1) elif args.all_contexts: contexts = all_ctx else: @@ -81,7 +86,46 @@ def run_setup(args): print("Run with --all-contexts to setup all, or --contexts ctx1,ctx2 to select specific ones.") sys.exit(0) - print(f"Setting up {len(contexts)} cluster(s)...\n") + print(f"Pre-flight: checking {len(contexts)} cluster(s)...\n") + reachable = {} + for ctx in contexts: + server = _get_server_url(kube_cmd, ctx) + if not server: + print(f" {ctx}: SKIP (no server URL in kubeconfig)") + continue + try: + subprocess.run( + [kube_cmd, "cluster-info", "--context", ctx], + capture_output=True, text=True, timeout=15, check=True + ) + reachable[ctx] = server + print(f" {ctx}: reachable ({server})") + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + print(f" {ctx}: SKIP (unreachable – try '{kube_cmd} login {server}' first)") + continue + + if not args.skip_rbac: + try: + result = subprocess.run( + [kube_cmd, "auth", "can-i", "create", "clusterroles", + "--context", ctx], + capture_output=True, text=True, timeout=10 + ) + if result.stdout.strip().lower() != "yes": + print(f" {ctx}: SKIP (insufficient permissions – " + f"cluster-admin required for RBAC setup, " + f"or use --skip-rbac if RBAC is already applied)") + del reachable[ctx] + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + print(f" {ctx}: SKIP (could not verify permissions)") + del reachable[ctx] + + if not reachable: + print("\nError: no eligible clusters found. Nothing to do.", file=sys.stderr) + sys.exit(1) + + print(f"\n{len(reachable)}/{len(contexts)} cluster(s) ready. " + f"Proceeding with setup...\n") inventory_file.parent.mkdir(parents=True, exist_ok=True) @@ -95,25 +139,8 @@ def run_setup(args): results = {"setup": [], "errors": []} - for ctx in contexts: + for ctx, server in reachable.items(): print(f"--- {ctx} ---") - - server = _get_server_url(kube_cmd, ctx) - if not server: - results["errors"].append(f"{ctx}: no server URL found in kubeconfig") - print(" SKIP: no server URL") - continue - - try: - subprocess.run( - [kube_cmd, "cluster-info", "--context", ctx], - capture_output=True, text=True, timeout=15, check=True - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - results["errors"].append(f"{ctx}: cluster unreachable or auth expired") - print(f" SKIP: cluster unreachable (try '{kube_cmd} login {server}' first)") - continue - print(f" Server: {server}") if args.skip_rbac: From 26d4a87d9fe7933c2fb6402312a91fac872a6ae3 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Mon, 9 Mar 2026 16:06:10 +0200 Subject: [PATCH 10/11] update find_kube_cmd() to prefer oc and warn on kubectl fallback --- .../scripts/cluster-report/build-kubeconfig.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/ocp-admin/scripts/cluster-report/build-kubeconfig.py b/ocp-admin/scripts/cluster-report/build-kubeconfig.py index 5d72f2a5..a4e06bc2 100644 --- a/ocp-admin/scripts/cluster-report/build-kubeconfig.py +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.py @@ -36,11 +36,18 @@ def find_kube_cmd(): - """Detect oc or kubectl in PATH.""" - for cmd in ("oc", "kubectl"): - if shutil.which(cmd): - return cmd - print('{"error": "Neither oc nor kubectl found in PATH"}', file=sys.stderr) + """Detect oc (preferred) or kubectl in PATH.""" + if shutil.which("oc"): + return "oc" + if shutil.which("kubectl"): + print("WARNING: 'oc' not found – falling back to 'kubectl'. " + "Install the OpenShift CLI (oc) for full compatibility: " + "https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/", + file=sys.stderr) + return "kubectl" + print('{"error": "Neither oc nor kubectl found in PATH. ' + 'Install oc: https://mirror.openshift.com/pub/openshift-v4/clients/ocp/stable/"}', + file=sys.stderr) sys.exit(1) From f8ce472e722e8dc85471ddc0727b7522bf494f24 Mon Sep 17 00:00:00 2001 From: ikrispin Date: Mon, 9 Mar 2026 16:17:23 +0200 Subject: [PATCH 11/11] add a bash wrapper to podman to conditionally add --userns only on Linux --- ocp-admin/.mcp.json | 15 +++------------ ocp-admin/README.md | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/ocp-admin/.mcp.json b/ocp-admin/.mcp.json index 894e7526..5cd15768 100644 --- a/ocp-admin/.mcp.json +++ b/ocp-admin/.mcp.json @@ -1,19 +1,10 @@ { "mcpServers": { "openshift": { - "command": "podman", + "command": "bash", "args": [ - "run", - "--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", - "--kubeconfig", "/kubeconfig", - "--read-only", - "--toolsets", "core,config" + "-c", + "U=(); [ \"$(uname -s)\" = Linux ] && U=(--userns=keep-id:uid=65532,gid=65532); exec podman run \"${U[@]}\" --rm -i --network=host -v \"${KUBECONFIG}:/kubeconfig:ro,Z\" --entrypoint /app/kubernetes-mcp-server quay.io/ecosystem-appeng/openshift-mcp-server:latest --kubeconfig /kubeconfig --read-only --toolsets core,config" ], "env": { "KUBECONFIG": "${KUBECONFIG}" diff --git a/ocp-admin/README.md b/ocp-admin/README.md index 8c28acc9..e6d222ee 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -62,4 +62,4 @@ Both scripts read from stdin and write to stdout. They are invoked as a pipeline - **openshift** - OpenShift cluster management with multi-cluster support -> **Container UID mapping**: The openshift MCP server uses `--userns=keep-id:uid=65532,gid=65532` to map the host user to the container's non-root UID (65532). This allows the container to read `chmod 600` files like `KUBECONFIG` without weakening file permissions. **macOS users**: Podman runs inside a VM on macOS — this flag may cause startup failures. If the MCP server fails to start, remove the `--userns` line from `.mcp.json`. +> **Container UID mapping**: On Linux, the MCP server automatically adds `--userns=keep-id:uid=65532,gid=65532` to map the host user to the container's non-root UID (65532), allowing the container to read `chmod 600` files like `KUBECONFIG` without weakening file permissions. On macOS the flag is omitted automatically since Podman runs inside a VM where `--userns` can cause startup failures.