mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-13 04:26:28 +03:00
262 lines
10 KiB
Python
Executable File
262 lines
10 KiB
Python
Executable File
#!/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())
|