The size cap was applied to the raw wire body, so a nodes list (huge because of status.images) truncated before the image-stripping ran and came back as a truncation notice. Accept up to 6 MiB on the wire to parse and clean, then enforce the 384 KiB model-facing cap on the stripped result - nodes now returns real data. Delivered via the cluster-read ConfigMap; picked up on the next pod roll. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""Read-only Atlas cluster visibility for chat tenants.
|
|
|
|
One GET-only tool against the in-cluster Kubernetes API using the pod's
|
|
projected service account. The real security boundary is RBAC (the
|
|
``view`` ClusterRole never includes Secrets, so Vault-managed material is
|
|
structurally invisible); this handler additionally refuses any path that
|
|
names the secrets resource, refuses every non-read shape, and bounds the
|
|
response so a huge list cannot flood the model context.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
TOKEN_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
|
CA_FILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
|
|
API_BASE = "https://kubernetes.default.svc"
|
|
MAX_RESPONSE_BYTES = 384 * 1024
|
|
MAX_WIRE_BYTES = 6 * 1024 * 1024 # accept a large response to clean, then cap the model-facing output
|
|
TIMEOUT_SECONDS = 8
|
|
SEGMENT = re.compile(r"^[a-z0-9][a-z0-9.\-]{0,252}$")
|
|
DENIED_RESOURCES = frozenset({"secrets"})
|
|
|
|
CLUSTER_READ_SCHEMA = {
|
|
"name": "cluster_read",
|
|
"description": (
|
|
"Read-only view of the Atlas Kubernetes cluster. List or get "
|
|
"resources such as pods, deployments, statefulsets, services, nodes, "
|
|
"namespaces, events, or Flux kustomizations/helmreleases. Read "
|
|
"access only: nothing can be created, changed, or deleted, and "
|
|
"Secrets (including everything Vault manages) are not accessible. "
|
|
"Use group='' for core resources (pods, services, nodes, events), "
|
|
"group='apps' for deployments/statefulsets, "
|
|
"group='kustomize.toolkit.fluxcd.io' for kustomizations. Omit "
|
|
"namespace for cluster-scoped or all-namespace lists; omit name to "
|
|
"list. Answers come back as compact JSON."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"resource": {
|
|
"type": "string",
|
|
"description": "Lowercase plural resource, e.g. pods, deployments, kustomizations",
|
|
},
|
|
"group": {
|
|
"type": "string",
|
|
"description": "API group; empty string for core resources",
|
|
},
|
|
"version": {
|
|
"type": "string",
|
|
"description": "API version, default v1",
|
|
},
|
|
"namespace": {
|
|
"type": "string",
|
|
"description": "Namespace; omit for cluster-scoped or all namespaces",
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "Object name for a single get; omit to list",
|
|
},
|
|
"label_selector": {
|
|
"type": "string",
|
|
"description": "Optional labelSelector for lists",
|
|
},
|
|
},
|
|
"required": ["resource"],
|
|
},
|
|
}
|
|
|
|
|
|
def _segment(value: Any, what: str) -> str:
|
|
if not isinstance(value, str) or not SEGMENT.fullmatch(value):
|
|
raise ValueError(f"{what} is not a valid lowercase Kubernetes name")
|
|
return value
|
|
|
|
|
|
def _build_path(args: dict[str, Any]) -> str:
|
|
resource = _segment(args.get("resource"), "resource")
|
|
if resource in DENIED_RESOURCES or "secret" in resource:
|
|
raise PermissionError("secrets are not readable from chat")
|
|
group = args.get("group") or ""
|
|
version = args.get("version") or "v1"
|
|
if group:
|
|
base = f"/apis/{_segment(group, 'group')}/{_segment(version, 'version')}"
|
|
else:
|
|
base = f"/api/{_segment(version, 'version')}"
|
|
namespace = args.get("namespace")
|
|
path = base
|
|
if namespace:
|
|
path += f"/namespaces/{_segment(namespace, 'namespace')}"
|
|
path += f"/{resource}"
|
|
name = args.get("name")
|
|
if name:
|
|
path += f"/{_segment(name, 'name')}"
|
|
selector = args.get("label_selector")
|
|
if selector:
|
|
if not isinstance(selector, str) or len(selector) > 200 or any(c in selector for c in " \n\r?#"):
|
|
raise ValueError("label_selector is malformed")
|
|
path += "?" + urllib.parse.urlencode({"labelSelector": selector, "limit": 200})
|
|
elif not name:
|
|
path += "?limit=200"
|
|
return path
|
|
|
|
|
|
def _strip_noise(payload: Any) -> Any:
|
|
"""Drop managedFields and annotations blobs that waste model context."""
|
|
if isinstance(payload, dict):
|
|
payload.pop("managedFields", None)
|
|
metadata = payload.get("metadata")
|
|
if isinstance(metadata, dict):
|
|
metadata.pop("managedFields", None)
|
|
annotations = metadata.get("annotations")
|
|
if isinstance(annotations, dict):
|
|
for key in [k for k in annotations if "last-applied" in k]:
|
|
annotations.pop(key, None)
|
|
status = payload.get("status")
|
|
if isinstance(status, dict) and isinstance(status.get("images"), list):
|
|
# Node .status.images is a huge blob of every cached image; it
|
|
# never helps the model and routinely overflows the size cap.
|
|
status["images"] = f"[{len(status['images'])} cached images omitted]"
|
|
for value in payload.values():
|
|
_strip_noise(value)
|
|
elif isinstance(payload, list):
|
|
for value in payload:
|
|
_strip_noise(value)
|
|
return payload
|
|
|
|
|
|
def _handle_cluster_read(args: dict[str, Any] | None = None, **kwargs: Any) -> str:
|
|
arguments = dict(args or {})
|
|
arguments.update({k: v for k, v in kwargs.items() if k in CLUSTER_READ_SCHEMA["parameters"]["properties"]})
|
|
try:
|
|
path = _build_path(arguments)
|
|
except (ValueError, PermissionError) as error:
|
|
return json.dumps({"error": str(error)})
|
|
try:
|
|
token = TOKEN_FILE.read_text().strip()
|
|
context = ssl.create_default_context(cafile=str(CA_FILE))
|
|
request = urllib.request.Request(
|
|
API_BASE + path,
|
|
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
|
method="GET",
|
|
)
|
|
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS, context=context) as response:
|
|
raw = response.read(MAX_WIRE_BYTES + 1)
|
|
except urllib.error.HTTPError as error:
|
|
detail = error.read(2048).decode("utf-8", "replace")
|
|
return json.dumps({"error": f"cluster API returned {error.code}", "detail": detail[:400]})
|
|
except Exception as error: # noqa: BLE001 - tool result, never an exception into the loop
|
|
return json.dumps({"error": f"cluster API unavailable: {type(error).__name__}"})
|
|
if len(raw) > MAX_WIRE_BYTES:
|
|
# Genuinely enormous even before cleanup: a truncated wire body is not
|
|
# parseable, so hand back the readable prefix and ask to narrow.
|
|
return json.dumps({"truncated": True, "reason": "response exceeded size cap; narrow with namespace/name/label_selector",
|
|
"partial": raw[: MAX_RESPONSE_BYTES - 96].decode("utf-8", "replace")})
|
|
try:
|
|
# Parse the full response first, then strip heavy fields (e.g. node
|
|
# image lists) so the model-facing output cap applies to clean data.
|
|
payload = _strip_noise(json.loads(raw))
|
|
except ValueError:
|
|
return json.dumps({"error": "cluster API returned non-JSON data"})
|
|
body = json.dumps(payload, separators=(",", ":"))
|
|
if len(body) > MAX_RESPONSE_BYTES:
|
|
return json.dumps({"truncated": True, "reason": "result exceeded size cap after cleanup; narrow the query",
|
|
"partial": body[: MAX_RESPONSE_BYTES - 96]})
|
|
return body
|
|
|
|
|
|
def _available() -> bool:
|
|
return TOKEN_FILE.is_file() and CA_FILE.is_file()
|
|
|
|
|
|
def register(ctx: Any) -> None:
|
|
"""Expose the read-only cluster tool to platforms that enable it."""
|
|
ctx.register_tool(
|
|
name="cluster_read",
|
|
toolset="cluster",
|
|
schema=CLUSTER_READ_SCHEMA,
|
|
handler=_handle_cluster_read,
|
|
check_fn=_available,
|
|
requires_env=[],
|
|
is_async=False,
|
|
description=CLUSTER_READ_SCHEMA["description"],
|
|
emoji="🔭",
|
|
)
|