diff --git a/ocp-admin/.mcp.json b/ocp-admin/.mcp.json index 1ccf863f..5cd15768 100644 --- a/ocp-admin/.mcp.json +++ b/ocp-admin/.mcp.json @@ -1,18 +1,10 @@ { "mcpServers": { "openshift": { - "command": "podman", + "command": "bash", "args": [ - "run", - "--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" + "-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 63b564ee..e6d222ee 100644 --- a/ocp-admin/README.md +++ b/ocp-admin/README.md @@ -16,6 +16,37 @@ 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.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. If RBAC has already been applied, use `--skip-rbac` to skip the apply step and only extract existing SA tokens. + +**Quick start:** + +```bash +# 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 + +# 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 +61,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**: 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. diff --git a/ocp-admin/docs/multi-cluster-auth.md b/ocp-admin/docs/multi-cluster-auth.md new file mode 100644 index 00000000..e187471b --- /dev/null +++ b/ocp-admin/docs/multi-cluster-auth.md @@ -0,0 +1,248 @@ +# 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 +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py setup --all-contexts + +# Step 2: Build — assembles a merged kubeconfig from the inventory +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 use the skill: /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 + +> **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 +``` + +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 + +```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. + +> **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`: + +```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 +python3 ocp-admin/scripts/cluster-report/build-kubeconfig.py \ + 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` Subcommand + +```bash +python3 build-kubeconfig.py 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` Subcommand + +```bash +python3 build-kubeconfig.py 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 +oc delete secret cluster-reporter-token -n cluster-reporter-system +oc apply -f ocp-admin/scripts/cluster-report/cluster-reporter-rbac.yaml + +oc get secret cluster-reporter-token -n cluster-reporter-system \ + -o jsonpath='{.data.token}' | base64 -d + +python3 build-kubeconfig.py build --clusters ~/.ocp-clusters/clusters.json --verify +``` + +To detect expired or invalid tokens: + +```bash +python3 build-kubeconfig.py build --clusters ~/.ocp-clusters/clusters.json --verify +``` + +## 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` +7. **Never display tokens in AI conversations** — verify tokens are set but never print, echo, or expose their values in output + +## 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.py b/ocp-admin/scripts/cluster-report/build-kubeconfig.py new file mode 100644 index 00000000..a4e06bc2 --- /dev/null +++ b/ocp-admin/scripts/cluster-report/build-kubeconfig.py @@ -0,0 +1,446 @@ +#!/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 (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) + + +# --------------------------------------------------------------------------- +# Setup mode +# --------------------------------------------------------------------------- + +def run_setup(args): + kube_cmd = find_kube_cmd() + inventory_file = Path(args.output_inventory) + + 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) + + 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(",") + 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: + 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"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) + + 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, server in reachable.items(): + print(f"--- {ctx} ---") + print(f" Server: {server}") + + 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) + 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, "get", "nodes", "--context", name, "-o", "name", "--no-headers"], + 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( + "--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}).", + ) + + # -- 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/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..545c565c 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.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 - [`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.py build --verify` or `oc login ` | | No GPUs found | Display 0 (not an error) | | Empty cluster | Report with all zeros (valid data) | 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