#!/usr/bin/env python3 """Agent-first Kubernetes CLI wrapper. This wrapper deliberately delegates Kubernetes semantics to kubectl. It adds bounded output, JSON contracts, explicit scope, mutation confirmation, and post-command-friendly exit behavior without embedding a stale API client. """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys from typing import Any SECRET_KEY = re.compile(r"(token|password|secret|certificate-authority-data|client-key-data|client-certificate-data)", re.I) MUTATING = {"apply", "delete", "scale", "restart", "patch", "drain", "cordon", "uncordon", "label", "annotate", "taint", "rollout-restart"} DESTRUCTIVE = {"delete", "drain", "patch", "scale", "restart", "cordon", "uncordon", "label", "annotate", "taint", "rollout-restart"} def redact(value: Any) -> Any: if isinstance(value, dict): return {k: ("[REDACTED]" if SECRET_KEY.search(str(k)) else redact(v)) for k, v in value.items()} if isinstance(value, list): return [redact(v) for v in value] return value def emit(payload: Any, as_json: bool) -> None: if as_json: print(json.dumps(redact(payload), indent=2, sort_keys=True)) elif isinstance(payload, str): print(payload, end="" if payload.endswith("\n") else "\n") else: print(json.dumps(redact(payload), indent=2, sort_keys=True)) def base_args(args: argparse.Namespace) -> list[str]: result: list[str] = [] if args.context: result += ["--context", args.context] if args.namespace: result += ["--namespace", args.namespace] return result def run_kubectl(parts: list[str], args: argparse.Namespace, *, mutation: bool = False, destructive: bool = False) -> int: binary = shutil.which(os.environ.get("KUBECTL", "kubectl")) if not binary: emit({"ok": False, "error": "kubectl not found", "hint": "Install kubectl or set KUBECTL to a compatible executable."}, args.json) return 127 if mutation and not args.dry_run and not args.yes: emit({"ok": False, "error": "mutation requires --yes", "command": parts, "hint": "Run with --dry-run first, then repeat with --yes after reviewing scope."}, args.json) return 2 command = [binary] + base_args(args) + parts if args.dry_run and mutation and "--dry-run" not in command: command.append("--dry-run=server") try: proc = subprocess.run(command, capture_output=True, text=True, timeout=args.timeout) except subprocess.TimeoutExpired: emit({"ok": False, "error": "kubectl timed out", "timeout_seconds": args.timeout, "command": command[1:]}, args.json) return 124 stdout = proc.stdout stderr = proc.stderr if args.json: payload: Any try: payload = json.loads(stdout) if stdout.strip() else {"stdout": ""} except json.JSONDecodeError: payload = {"stdout": stdout, "stderr": stderr} if isinstance(payload, dict): payload = {"ok": proc.returncode == 0, "exit_code": proc.returncode, "result": payload} else: payload = {"ok": proc.returncode == 0, "exit_code": proc.returncode, "result": payload} if stderr: payload["stderr"] = stderr emit(payload, True) else: if stdout: print(stdout, end="") if stderr: print(stderr, end="", file=sys.stderr) return proc.returncode def add_common(parser: argparse.ArgumentParser) -> None: parser.add_argument("--context", help="Explicit kubeconfig context") parser.add_argument("--namespace", "-n", help="Explicit namespace") parser.add_argument("--json", action="store_true", help="Emit a structured JSON result") parser.add_argument("--timeout", type=int, default=60, help="kubectl timeout in seconds") parser.add_argument("--yes", action="store_true", help="Confirm a mutating operation") parser.add_argument("--dry-run", action="store_true", help="Use server-side dry-run for mutations") def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="k8s-cli", description="Bounded, agent-first wrapper around kubectl") add_common(p) sub = p.add_subparsers(dest="command", required=True) sub.add_parser("doctor", help="Check binary, context, version, readiness, and discovery") sub.add_parser("context", help="Show current context and server/client version") sub.add_parser("discover", help="Show served resources, versions, and CRDs") get = sub.add_parser("get", help="Get a resource using bounded JSON-friendly output") get.add_argument("resource") get.add_argument("name", nargs="?") get.add_argument("--selector", "-l") get.add_argument("--all-namespaces", action="store_true") desc = sub.add_parser("describe", help="Describe a resource") desc.add_argument("resource") desc.add_argument("name") events = sub.add_parser("events", help="List recent events") events.add_argument("--tail", type=int, default=100) logs = sub.add_parser("logs", help="Fetch bounded logs") logs.add_argument("pod") logs.add_argument("--container", "-c") logs.add_argument("--tail", type=int, default=200) logs.add_argument("--since", default="1h") logs.add_argument("--previous", action="store_true") rollout = sub.add_parser("rollout", help="Inspect or mutate a rollout") rollout.add_argument("action", choices=["status", "history", "undo", "restart"]) rollout.add_argument("resource") rollout.add_argument("--revision", type=int) apply = sub.add_parser("apply", help="Server-validate or apply a manifest") apply.add_argument("file") apply.add_argument("--field-manager", default="agent-kubernetes") apply.add_argument("--force-conflicts", action="store_true") delete = sub.add_parser("delete", help="Preview or delete a resource") delete.add_argument("resource") delete.add_argument("name", nargs="?") delete.add_argument("--all", action="store_true") auth = sub.add_parser("can-i", help="Check effective authorization") auth.add_argument("verb") auth.add_argument("resource") raw = sub.add_parser("raw", help="Call a bounded raw API path") raw.add_argument("path") return p def normalize_argv(argv: list[str]) -> list[str]: """Allow global safety/output flags before or after the subcommand.""" value_flags = {"--context", "--namespace", "-n", "--timeout"} boolean_flags = {"--json", "--yes", "--dry-run"} prefix: list[str] = [] rest: list[str] = [] i = 0 while i < len(argv): token = argv[i] if token in value_flags and i + 1 < len(argv): prefix.extend([token, argv[i + 1]]) i += 2 continue if token in boolean_flags: prefix.append(token) i += 1 continue if any(token.startswith(flag + "=") for flag in value_flags): prefix.append(token) i += 1 continue rest.append(token) i += 1 return prefix + rest def main() -> int: args = parser().parse_args(normalize_argv(sys.argv[1:])) c = args.command if c == "doctor": rc = run_kubectl(["version", "-o", "json"], args) if rc: return rc for command in (["config", "current-context"], ["get", "--raw=/readyz?verbose"], ["api-resources", "-o", "wide"]): rc = run_kubectl(command, args) if rc: return rc return 0 if c == "context": return run_kubectl(["config", "current-context"], args) or run_kubectl(["version", "-o", "json"], args) if c == "discover": rc = run_kubectl(["api-resources", "-o", "wide"], args) if rc: return rc rc = run_kubectl(["api-versions"], args) if rc: return rc return run_kubectl(["get", "crd"], args) if c == "get": parts = ["get", args.resource] + ([args.name] if args.name else []) if args.selector: parts += ["--selector", args.selector] if args.all_namespaces: parts.append("--all-namespaces") if args.json: parts += ["-o", "json"] return run_kubectl(parts, args) if c == "describe": return run_kubectl(["describe", args.resource, args.name], args) if c == "events": parts = ["get", "events", "--sort-by=.lastTimestamp", f"--chunk-size={args.tail}"] if args.json: parts += ["-o", "json"] return run_kubectl(parts, args) if c == "logs": parts = ["logs", args.pod, "--tail", str(args.tail), "--since", args.since] if args.container: parts += ["--container", args.container] if args.previous: parts.append("--previous") return run_kubectl(parts, args) if c == "rollout": action = args.action if action == "status": return run_kubectl(["rollout", "status", args.resource], args) if action == "history": return run_kubectl(["rollout", "history", args.resource], args) if action == "undo": parts = ["rollout", "undo", args.resource] if args.revision: parts += [f"--to-revision={args.revision}"] return run_kubectl(parts, args, mutation=True, destructive=True) return run_kubectl(["rollout", "restart", args.resource], args, mutation=True, destructive=True) if c == "apply": if args.force_conflicts and not args.yes: emit({"ok": False, "error": "--force-conflicts requires --yes"}, args.json) return 2 parts = ["apply", "--server-side", f"--field-manager={args.field_manager}"] if args.force_conflicts: parts.append("--force-conflicts") parts += ["-f", args.file] return run_kubectl(parts, args, mutation=True) if c == "delete": parts = ["delete", args.resource] if args.name: parts.append(args.name) if args.all: parts.append("--all") return run_kubectl(parts, args, mutation=True, destructive=True) if c == "can-i": return run_kubectl(["auth", "can-i", args.verb, args.resource], args) if c == "raw": if not args.path.startswith("/") or any(x in args.path for x in ("..", "\n", "\r")): emit({"ok": False, "error": "raw path must be an absolute, traversal-free API path"}, args.json) return 2 return run_kubectl(["get", "--raw", args.path], args) return 2 if __name__ == "__main__": raise SystemExit(main())