add k8s read-only troubleshooting extension
A new extensions/k8s/ extension giving the agent read-only Kubernetes
tools (k8s_get, k8s_describe, k8s_logs, k8s_events, k8s_top, k8s_explain,
k8s_context). Credentials: KUBE_TOKEN from the env, cluster URL from
KUBE_APISERVER env or ~/.pi/agent/k8s.json (editable via the /k8s panel).
All kubectl invocations go through one exec.ts chokepoint that prepends
--server/--token; the token never reaches any render path. A /k8s panel
runs reachability + can-i probes and lets the user set the cluster URL
(press e). A bundled "k8s" mode restricts the toolset to read + grep +
k8s_* for focused troubleshooting. README ships the SA + RBAC manifest.
Container changes (Dockerfile):
- install kubectl
- bake customizations into /app (OpenShell Landlock allowlists /app,
not /opt) and chmod world-readable so the remapped sandbox uid can
read them
- openshell-policy.yaml: baseline filesystem/landlock/process policy
Also deletes the unused status-line.ts demo extension.
Known limitation: does NOT work inside an OpenShell sandbox yet —
OpenShell hardcodes the k8s control-plane ports (6443 et al.) as
universally blocked in its SSRF engine with no override. Documented in
extensions/k8s/README.md ("Why not OpenShell yet"). The extension works
when pi runs directly on a host with cluster access.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7c650dd13e
commit
415150c6a3
+16
-5
@@ -22,12 +22,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ARG PI_VERSION=0.75.3
|
||||
RUN npm install -g @earendil-works/pi-coding-agent@${PI_VERSION}
|
||||
|
||||
# kubectl for the k8s extension. Pinned for reproducible builds; bump with
|
||||
# --build-arg KUBECTL_VERSION=vX.Y.Z. The client is version-skew tolerant
|
||||
# within ±1 minor against the target cluster.
|
||||
ARG KUBECTL_VERSION=v1.29.3
|
||||
RUN curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
|
||||
-o /usr/local/bin/kubectl && \
|
||||
chmod 0755 /usr/local/bin/kubectl
|
||||
|
||||
RUN userdel -r node && groupdel node 2>/dev/null; \
|
||||
groupadd -g 1000 sandbox && \
|
||||
useradd -u 1000 -g 1000 -d /sandbox -s /bin/bash sandbox
|
||||
|
||||
# Bake this pi-customizations package into the image.
|
||||
COPY --chown=sandbox:sandbox . /opt/pi-customizations
|
||||
# Bake this pi-customizations package into the image at /app/pi-customizations.
|
||||
# OpenShell's default Landlock policy allowlists /app for read access from the
|
||||
# sandbox; /opt is not on the allowlist, so installing there causes
|
||||
# "Permission denied" inside the sandbox even with permissive Unix perms.
|
||||
COPY --chown=sandbox:sandbox . /app/pi-customizations
|
||||
|
||||
# Writable sandbox workdir owned by the sandbox user.
|
||||
RUN install -d -o sandbox -g sandbox /sandbox
|
||||
@@ -35,15 +46,15 @@ WORKDIR /sandbox
|
||||
|
||||
USER sandbox
|
||||
|
||||
# Install this package's runtime dependencies into /opt/pi-customizations/node_modules.
|
||||
# Install this package's runtime dependencies into /app/pi-customizations/node_modules.
|
||||
# pi-provided imports (@earendil-works/*, typebox) resolve from pi's global install,
|
||||
# but the websearch extension also pulls in linkedom + readability + turndown +
|
||||
# turndown-plugin-gfm (declared in dependencies); those need to be present locally.
|
||||
RUN cd /opt/pi-customizations && npm ci --omit=dev --no-audit --no-fund
|
||||
RUN cd /app/pi-customizations && npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
# Register the customizations with pi. A local-path install is recorded in
|
||||
# ~/.pi/agent/settings.json without copying.
|
||||
RUN pi install /opt/pi-customizations
|
||||
RUN pi install /app/pi-customizations
|
||||
|
||||
# Informational only — OpenShell's supervisor overrides this; launch with `-- pi`.
|
||||
CMD ["pi"]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# k8s extension — setup
|
||||
|
||||
Read-only Kubernetes troubleshooting via `kubectl`.
|
||||
|
||||
> **OpenShell status:** does NOT work inside an NVIDIA OpenShell sandbox yet (see [Why not OpenShell yet](#why-not-openshell-yet) below). Use this extension by running pi directly on a host that already has cluster access.
|
||||
|
||||
## Cluster-side setup
|
||||
|
||||
Apply this manifest to your cluster (one-time, by an admin). The ServiceAccount lives in the `apps` namespace but the bindings are cluster-scoped (`ClusterRoleBinding`), so the SA can read across all namespaces:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: openshell-k8s-readonly
|
||||
namespace: apps
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: openshell-k8s-readonly-view
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: view
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: openshell-k8s-readonly
|
||||
namespace: apps
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: openshell-k8s-readonly-extras
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log", "nodes"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["metrics.k8s.io"]
|
||||
resources: ["pods", "nodes"]
|
||||
verbs: ["get", "list"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: openshell-k8s-readonly-extras
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: openshell-k8s-readonly-extras
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: openshell-k8s-readonly
|
||||
namespace: apps
|
||||
```
|
||||
|
||||
Mint a long-lived token (Kubernetes ≥1.24):
|
||||
|
||||
```sh
|
||||
kubectl create token openshell-k8s-readonly -n apps --duration=8760h
|
||||
```
|
||||
|
||||
## Running pi against the cluster
|
||||
|
||||
Set the two env vars before launching pi:
|
||||
|
||||
```sh
|
||||
export KUBE_APISERVER=https://your-cluster-api:6443
|
||||
export KUBE_TOKEN="$(cat /path/to/sa.token)"
|
||||
pi
|
||||
```
|
||||
|
||||
Or set only `KUBE_TOKEN` and configure the URL from inside pi via `/k8s` → press `e`. The URL is persisted to `~/.pi/agent/k8s.json`.
|
||||
|
||||
## Inside pi
|
||||
|
||||
Tools are active when `KUBE_TOKEN` is present in the env and a cluster URL is configured (either via `KUBE_APISERVER` env or via `/k8s` → edit cluster URL). Otherwise tools register but return a not-configured message.
|
||||
|
||||
`/k8s` opens a diagnostic panel: server reachability, identity, and `can-i` probes for the verbs the extension uses. From the panel:
|
||||
|
||||
- press `r` to refresh
|
||||
- press `e` to set / edit the cluster URL (saved to `~/.pi/agent/k8s.json`)
|
||||
- press `Esc` to close
|
||||
|
||||
The URL precedence is `KUBE_APISERVER` env first, then `~/.pi/agent/k8s.json`. The token is always taken from `KUBE_TOKEN` env.
|
||||
|
||||
`/mode k8s` switches to the focused k8s troubleshooting mode (read/grep/find/ls + k8s_*).
|
||||
|
||||
## Tools
|
||||
|
||||
- `k8s_get` — list/fetch resources (default `-A`)
|
||||
- `k8s_describe` — detail for one resource
|
||||
- `k8s_logs` — pod logs (tail capped at 2000)
|
||||
- `k8s_events` — recent events
|
||||
- `k8s_top` — CPU/mem (requires metrics-server)
|
||||
- `k8s_explain` — resource schema
|
||||
- `k8s_context` — report server + identity
|
||||
|
||||
No write verbs. No `exec` / `port-forward` / `cp`. The cluster-side SA RBAC also denies these as defense in depth.
|
||||
|
||||
## Why not OpenShell yet
|
||||
|
||||
The OpenShell sandbox SSRF engine hardcodes the standard Kubernetes control-plane ports — including `6443` — as universally blocked, with no policy field or setting to relax it (see `BLOCKED_CONTROL_PLANE_PORTS` in `crates/openshell-sandbox/src/proxy.rs` in NVIDIA/OpenShell). The sandbox proxy refuses to open a TCP connection to those ports regardless of `network_policies` or provider profiles.
|
||||
|
||||
We tried a TCP/TLS proxy on a non-blocked port (8443) backed by a custom OpenShell provider profile with `auth_style: bearer`. The OPA layer allowed it once the profile was in place, but credential rewriting then runs into proxy ↔ apiserver TLS verification + bearer-substitution edge cases that aren't cleanly solvable without either patching OpenShell or running a more involved L7-aware proxy in front of the apiserver. Not worth the complexity for a personal setup yet.
|
||||
|
||||
Re-open this once OpenShell exposes a way to opt out of (or scope) the control-plane port block, e.g. via the policy YAML.
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { configPath, readK8sConfig, writeK8sConfig } from "./config.js";
|
||||
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalHome = process.env.HOME;
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-k8s-config-"));
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("k8s config", () => {
|
||||
it("configPath lives under ~/.pi/agent/k8s.json", () => {
|
||||
expect(configPath()).toBe(path.join(tmpHome, ".pi", "agent", "k8s.json"));
|
||||
});
|
||||
|
||||
it("returns an empty config when the file is missing", () => {
|
||||
expect(readK8sConfig()).toEqual({ version: 1 });
|
||||
});
|
||||
|
||||
it("writeK8sConfig then readK8sConfig round-trips apiServer", () => {
|
||||
writeK8sConfig({ version: 1, apiServer: "https://api.cluster.example:6443" });
|
||||
expect(readK8sConfig()).toEqual({
|
||||
version: 1,
|
||||
apiServer: "https://api.cluster.example:6443",
|
||||
});
|
||||
});
|
||||
|
||||
it("writeK8sConfig sets file mode to 0600", () => {
|
||||
writeK8sConfig({ version: 1, apiServer: "https://x" });
|
||||
const mode = fs.statSync(configPath()).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it("ignores a config with the wrong version", () => {
|
||||
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath(),
|
||||
JSON.stringify({ version: 2, apiServer: "https://x" }),
|
||||
);
|
||||
expect(readK8sConfig()).toEqual({ version: 1 });
|
||||
});
|
||||
|
||||
it("ignores an unparseable file", () => {
|
||||
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
||||
fs.writeFileSync(configPath(), "not json");
|
||||
expect(readK8sConfig()).toEqual({ version: 1 });
|
||||
});
|
||||
|
||||
it("treats empty / whitespace-only apiServer as undefined", () => {
|
||||
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath(),
|
||||
JSON.stringify({ version: 1, apiServer: " " }),
|
||||
);
|
||||
expect(readK8sConfig().apiServer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface K8sConfig {
|
||||
version: 1;
|
||||
apiServer?: string;
|
||||
}
|
||||
|
||||
export function configPath(): string {
|
||||
return path.join(getAgentDir(), "k8s.json");
|
||||
}
|
||||
|
||||
export function readK8sConfig(): K8sConfig {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
(parsed as { version?: unknown }).version === 1
|
||||
) {
|
||||
const p = parsed as { apiServer?: unknown };
|
||||
const apiServer =
|
||||
typeof p.apiServer === "string" && p.apiServer.trim() !== ""
|
||||
? p.apiServer.trim()
|
||||
: undefined;
|
||||
return { version: 1, apiServer };
|
||||
}
|
||||
} catch {}
|
||||
return { version: 1 };
|
||||
}
|
||||
|
||||
export function writeK8sConfig(cfg: K8sConfig): void {
|
||||
const dir = getAgentDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath(),
|
||||
`${JSON.stringify(cfg, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runDiagnostics, type DiagRunner } from "./diag.js";
|
||||
import type { K8sEnv } from "./exec.js";
|
||||
|
||||
const env: K8sEnv = {
|
||||
apiServer: "https://api.cluster.example:6443",
|
||||
token: "tok",
|
||||
insecureSkipTls: false,
|
||||
};
|
||||
|
||||
function makeRunner(
|
||||
responses: Record<string, { stdout: string; stderr: string; kind: "ok" | "forbidden" | "notfound" | "network" | "timeout" | "other" }>,
|
||||
): DiagRunner {
|
||||
return async (verb, args) => {
|
||||
const key = `${verb} ${args.join(" ")}`;
|
||||
const r = responses[key];
|
||||
if (!r) throw new Error(`unstubbed: ${key}`);
|
||||
return {
|
||||
ok: r.kind === "ok",
|
||||
exitCode: r.kind === "ok" ? 0 : 1,
|
||||
kind: r.kind,
|
||||
stdout: r.stdout,
|
||||
stderr: r.stderr,
|
||||
verb,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("runDiagnostics", () => {
|
||||
it("collects server version, identity, and per-row can-i answers", async () => {
|
||||
const responses: Record<string, any> = {
|
||||
"version ": { kind: "ok", stdout: "Server Version: v1.29.3\n", stderr: "" },
|
||||
"auth whoami -o jsonpath={.status.userInfo.username}": {
|
||||
kind: "ok",
|
||||
stdout: "system:serviceaccount:pi/pi-readonly",
|
||||
stderr: "",
|
||||
},
|
||||
};
|
||||
for (const [verb, resource] of [
|
||||
["get", "pods"],
|
||||
["list", "pods"],
|
||||
["get", "pods/log"],
|
||||
["get", "events"],
|
||||
["get", "nodes"],
|
||||
["get", "pods.metrics.k8s.io"],
|
||||
["create", "pods"],
|
||||
["delete", "pods"],
|
||||
["patch", "deployments"],
|
||||
]) {
|
||||
const allow = !["create", "delete", "patch"].includes(verb);
|
||||
responses[`auth can-i ${verb} ${resource}`] = {
|
||||
kind: allow ? "ok" : "forbidden",
|
||||
stdout: allow ? "yes\n" : "",
|
||||
stderr: allow ? "" : "Forbidden",
|
||||
};
|
||||
}
|
||||
const report = await runDiagnostics(makeRunner(responses), env);
|
||||
expect(report.reachable).toBe(true);
|
||||
expect(report.serverVersion).toContain("v1.29.3");
|
||||
expect(report.identity).toBe("system:serviceaccount:pi/pi-readonly");
|
||||
expect(report.allowed.find((p) => p.label.includes("get pods"))?.allowed).toBe(true);
|
||||
expect(report.denied.find((p) => p.label.includes("create pods"))?.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back from auth whoami when forbidden / unavailable", async () => {
|
||||
const responses: Record<string, any> = {
|
||||
"version ": { kind: "ok", stdout: "v1.27.0", stderr: "" },
|
||||
"auth whoami -o jsonpath={.status.userInfo.username}": {
|
||||
kind: "forbidden",
|
||||
stdout: "",
|
||||
stderr: "Forbidden",
|
||||
},
|
||||
"auth can-i --list": {
|
||||
kind: "ok",
|
||||
stdout: "Resources Non-Resource URLs Resource Names Verbs\n",
|
||||
stderr: "",
|
||||
},
|
||||
};
|
||||
for (const [verb, resource] of [
|
||||
["get", "pods"], ["list", "pods"], ["get", "pods/log"],
|
||||
["get", "events"], ["get", "nodes"], ["get", "pods.metrics.k8s.io"],
|
||||
["create", "pods"], ["delete", "pods"], ["patch", "deployments"],
|
||||
]) {
|
||||
responses[`auth can-i ${verb} ${resource}`] = { kind: "ok", stdout: "yes", stderr: "" };
|
||||
}
|
||||
const report = await runDiagnostics(makeRunner(responses), env);
|
||||
expect(report.identity).toMatch(/unknown|forbidden/i);
|
||||
});
|
||||
|
||||
it("returns reachable=false when the first probe fails with network", async () => {
|
||||
const runner: DiagRunner = async () => ({
|
||||
ok: false,
|
||||
exitCode: 1,
|
||||
kind: "network",
|
||||
stdout: "",
|
||||
stderr: "Unable to connect to the server",
|
||||
verb: "version",
|
||||
});
|
||||
const report = await runDiagnostics(runner, env);
|
||||
expect(report.reachable).toBe(false);
|
||||
expect(report.error).toContain("network");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { K8sEnv, KubectlResult } from "./exec.js";
|
||||
|
||||
export type DiagRunner = (verb: string, args: string[]) => Promise<KubectlResult>;
|
||||
|
||||
export interface PermissionRow {
|
||||
label: string;
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export interface DiagnosticsReport {
|
||||
server: string;
|
||||
reachable: boolean;
|
||||
serverVersion?: string;
|
||||
identity: string;
|
||||
allowed: PermissionRow[];
|
||||
denied: PermissionRow[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ALLOW_PROBES: Array<[string, string, string]> = [
|
||||
["get", "pods", "can-i get pods"],
|
||||
["list", "pods", "can-i list pods"],
|
||||
["get", "pods/log", "can-i get pods/log"],
|
||||
["get", "events", "can-i get events"],
|
||||
["get", "nodes", "can-i get nodes"],
|
||||
["get", "pods.metrics.k8s.io", "can-i get pods.metrics.k8s.io"],
|
||||
];
|
||||
|
||||
const DENY_PROBES: Array<[string, string, string]> = [
|
||||
["create", "pods", "can-i create pods"],
|
||||
["delete", "pods", "can-i delete pods"],
|
||||
["patch", "deployments", "can-i patch deployments"],
|
||||
];
|
||||
|
||||
export async function runDiagnostics(
|
||||
run: DiagRunner,
|
||||
env: K8sEnv,
|
||||
): Promise<DiagnosticsReport> {
|
||||
const versionRes = await run("version", []);
|
||||
if (versionRes.kind === "network" || versionRes.kind === "timeout") {
|
||||
return {
|
||||
server: env.apiServer,
|
||||
reachable: false,
|
||||
identity: "(unknown — server unreachable)",
|
||||
allowed: [],
|
||||
denied: [],
|
||||
error: `${versionRes.kind}: ${versionRes.stderr || "no response"}`,
|
||||
};
|
||||
}
|
||||
|
||||
const whoamiRes = await run("auth", [
|
||||
"whoami",
|
||||
"-o",
|
||||
"jsonpath={.status.userInfo.username}",
|
||||
]);
|
||||
let identity = "(unknown identity)";
|
||||
if (whoamiRes.kind === "ok" && whoamiRes.stdout.trim()) {
|
||||
identity = whoamiRes.stdout.trim();
|
||||
} else {
|
||||
const fallback = await run("auth", ["can-i", "--list"]);
|
||||
if (fallback.kind === "forbidden") identity = "(forbidden to introspect identity)";
|
||||
else if (fallback.kind !== "ok") identity = "(unknown identity)";
|
||||
}
|
||||
|
||||
const probe = async (
|
||||
entries: Array<[string, string, string]>,
|
||||
): Promise<PermissionRow[]> => {
|
||||
const rows: PermissionRow[] = [];
|
||||
for (const [verb, resource, label] of entries) {
|
||||
const res = await run("auth", ["can-i", verb, resource]);
|
||||
rows.push({ label, allowed: res.kind === "ok" });
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const allowed = await probe(ALLOW_PROBES);
|
||||
const denied = await probe(DENY_PROBES);
|
||||
|
||||
return {
|
||||
server: env.apiServer,
|
||||
reachable: true,
|
||||
serverVersion: versionRes.stdout.trim() || undefined,
|
||||
identity,
|
||||
allowed,
|
||||
denied,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKubectlArgs,
|
||||
classifyKubectlResult,
|
||||
readK8sEnv,
|
||||
runKubectl,
|
||||
type K8sEnv,
|
||||
} from "./exec.js";
|
||||
|
||||
describe("readK8sEnv", () => {
|
||||
it("returns the env when both vars are set and URL is valid", () => {
|
||||
const r = readK8sEnv({
|
||||
KUBE_APISERVER: "https://api.cluster.example:6443",
|
||||
KUBE_TOKEN: "tok",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.env.apiServer).toBe("https://api.cluster.example:6443");
|
||||
expect(r.env.token).toBe("tok");
|
||||
expect(r.env.insecureSkipTls).toBe(false);
|
||||
});
|
||||
|
||||
it("flags missing KUBE_APISERVER", () => {
|
||||
const r = readK8sEnv({ KUBE_TOKEN: "tok" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("flags missing KUBE_TOKEN", () => {
|
||||
const r = readK8sEnv({ KUBE_APISERVER: "https://x" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a malformed KUBE_APISERVER URL", () => {
|
||||
const r = readK8sEnv({
|
||||
KUBE_APISERVER: "not-a-url",
|
||||
KUBE_TOKEN: "tok",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.error.toLowerCase()).toContain("url");
|
||||
});
|
||||
|
||||
it("honors PI_K8S_INSECURE_SKIP_TLS=1", () => {
|
||||
const r = readK8sEnv({
|
||||
KUBE_APISERVER: "https://x",
|
||||
KUBE_TOKEN: "tok",
|
||||
PI_K8S_INSECURE_SKIP_TLS: "1",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.env.insecureSkipTls).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKubectlArgs", () => {
|
||||
const env: K8sEnv = {
|
||||
apiServer: "https://api.cluster.example:6443",
|
||||
token: "tok",
|
||||
insecureSkipTls: false,
|
||||
};
|
||||
|
||||
it("prepends --server and --token before verb args", () => {
|
||||
const args = buildKubectlArgs(env, "get", ["pod", "-A"]);
|
||||
expect(args).toEqual([
|
||||
"--server=https://api.cluster.example:6443",
|
||||
"--token=tok",
|
||||
"get",
|
||||
"pod",
|
||||
"-A",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds --insecure-skip-tls-verify when env.insecureSkipTls is true", () => {
|
||||
const args = buildKubectlArgs(
|
||||
{ ...env, insecureSkipTls: true },
|
||||
"get",
|
||||
["pod"],
|
||||
);
|
||||
expect(args).toContain("--insecure-skip-tls-verify=true");
|
||||
});
|
||||
|
||||
it("contains the token only inside the --token= flag (hygiene)", () => {
|
||||
const secret = "super-secret-token-value-xyz";
|
||||
const args = buildKubectlArgs(
|
||||
{
|
||||
apiServer: "https://api.cluster.example:6443",
|
||||
token: secret,
|
||||
insecureSkipTls: false,
|
||||
},
|
||||
"get",
|
||||
["pod", "-A", "-o", "wide"],
|
||||
);
|
||||
const tokenOccurrences = args.filter((a) => a.includes(secret));
|
||||
expect(tokenOccurrences).toEqual([`--token=${secret}`]);
|
||||
expect(args.slice(2).some((a) => a.includes(secret))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyKubectlResult", () => {
|
||||
it("exit 0 → ok", () => {
|
||||
const r = classifyKubectlResult(
|
||||
{ stdout: "data", stderr: "", code: 0, killed: false },
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("ok");
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("killed=true → timeout", () => {
|
||||
const r = classifyKubectlResult(
|
||||
{ stdout: "", stderr: "", code: 1, killed: true },
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("timeout");
|
||||
});
|
||||
|
||||
it('stderr containing "forbidden" → forbidden', () => {
|
||||
const r = classifyKubectlResult(
|
||||
{
|
||||
stdout: "",
|
||||
stderr: 'Error from server (Forbidden): pods is forbidden',
|
||||
code: 1,
|
||||
killed: false,
|
||||
},
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("forbidden");
|
||||
});
|
||||
|
||||
it('stderr containing "not found" → notfound', () => {
|
||||
const r = classifyKubectlResult(
|
||||
{
|
||||
stdout: "",
|
||||
stderr: 'Error from server (NotFound): pods "api" not found',
|
||||
code: 1,
|
||||
killed: false,
|
||||
},
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("notfound");
|
||||
});
|
||||
|
||||
it("stderr mentioning network failure → network", () => {
|
||||
const r = classifyKubectlResult(
|
||||
{
|
||||
stdout: "",
|
||||
stderr: "Unable to connect to the server: dial tcp ...",
|
||||
code: 1,
|
||||
killed: false,
|
||||
},
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("network");
|
||||
});
|
||||
|
||||
it("non-zero exit with no marker → other", () => {
|
||||
const r = classifyKubectlResult(
|
||||
{ stdout: "", stderr: "weird", code: 1, killed: false },
|
||||
"get",
|
||||
);
|
||||
expect(r.kind).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runKubectl", () => {
|
||||
const env: K8sEnv = {
|
||||
apiServer: "https://api.cluster.example:6443",
|
||||
token: "tok",
|
||||
insecureSkipTls: false,
|
||||
};
|
||||
|
||||
it("invokes pi.exec with kubectl + built args and returns a classified result", async () => {
|
||||
let capturedCommand = "";
|
||||
let capturedArgs: string[] = [];
|
||||
const fakePi = {
|
||||
exec: async (cmd: string, args: string[]) => {
|
||||
capturedCommand = cmd;
|
||||
capturedArgs = args;
|
||||
return { stdout: "ok-out", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
};
|
||||
const r = await runKubectl(fakePi as any, env, "get", ["pod", "-A"]);
|
||||
expect(capturedCommand).toBe("kubectl");
|
||||
expect(capturedArgs.slice(0, 2)).toEqual([
|
||||
"--server=https://api.cluster.example:6443",
|
||||
"--token=tok",
|
||||
]);
|
||||
expect(capturedArgs.slice(2)).toEqual(["get", "pod", "-A"]);
|
||||
expect(r.kind).toBe("ok");
|
||||
expect(r.stdout).toBe("ok-out");
|
||||
});
|
||||
|
||||
it("passes signal and timeout through to pi.exec", async () => {
|
||||
let capturedOpts: any;
|
||||
const fakePi = {
|
||||
exec: async (_c: string, _a: string[], opts?: any) => {
|
||||
capturedOpts = opts;
|
||||
return { stdout: "", stderr: "", code: 0, killed: false };
|
||||
},
|
||||
};
|
||||
const signal = new AbortController().signal;
|
||||
await runKubectl(fakePi as any, env, "get", ["pod"], {
|
||||
signal,
|
||||
timeoutMs: 12345,
|
||||
});
|
||||
expect(capturedOpts.signal).toBe(signal);
|
||||
expect(capturedOpts.timeout).toBe(12345);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export interface K8sEnv {
|
||||
apiServer: string;
|
||||
token: string;
|
||||
insecureSkipTls: boolean;
|
||||
}
|
||||
|
||||
export type ReadK8sEnvResult =
|
||||
| { ok: true; env: K8sEnv }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function readK8sEnv(env: NodeJS.ProcessEnv | Record<string, string | undefined>): ReadK8sEnvResult {
|
||||
const apiServer = env.KUBE_APISERVER;
|
||||
const token = env.KUBE_TOKEN;
|
||||
if (!apiServer) return { ok: false, error: "KUBE_APISERVER is not set" };
|
||||
if (!token) return { ok: false, error: "KUBE_TOKEN is not set" };
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(apiServer);
|
||||
} catch {
|
||||
return { ok: false, error: `KUBE_APISERVER is not a valid URL: ${apiServer}` };
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return { ok: false, error: `KUBE_APISERVER must be http(s); got ${parsed.protocol}` };
|
||||
}
|
||||
const insecureSkipTls = env.PI_K8S_INSECURE_SKIP_TLS === "1";
|
||||
return { ok: true, env: { apiServer, token, insecureSkipTls } };
|
||||
}
|
||||
|
||||
export function buildKubectlArgs(env: K8sEnv, verb: string, verbArgs: string[]): string[] {
|
||||
const args = [`--server=${env.apiServer}`, `--token=${env.token}`];
|
||||
if (env.insecureSkipTls) args.push("--insecure-skip-tls-verify=true");
|
||||
args.push(verb, ...verbArgs);
|
||||
return args;
|
||||
}
|
||||
|
||||
export type KubectlKind =
|
||||
| "ok"
|
||||
| "forbidden"
|
||||
| "notfound"
|
||||
| "network"
|
||||
| "timeout"
|
||||
| "other";
|
||||
|
||||
export interface KubectlResult {
|
||||
ok: boolean;
|
||||
exitCode: number;
|
||||
kind: KubectlKind;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
verb: string;
|
||||
}
|
||||
|
||||
export function classifyKubectlResult(
|
||||
exec: { stdout: string; stderr: string; code: number; killed: boolean },
|
||||
verb: string,
|
||||
): KubectlResult {
|
||||
const base = {
|
||||
exitCode: exec.code,
|
||||
stdout: exec.stdout,
|
||||
stderr: exec.stderr,
|
||||
verb,
|
||||
};
|
||||
if (exec.killed) return { ...base, ok: false, kind: "timeout" };
|
||||
if (exec.code === 0) return { ...base, ok: true, kind: "ok" };
|
||||
const s = exec.stderr.toLowerCase();
|
||||
if (/forbidden/.test(s) || /cannot.*access/.test(s)) {
|
||||
return { ...base, ok: false, kind: "forbidden" };
|
||||
}
|
||||
if (/notfound/.test(s) || /not found/.test(s)) {
|
||||
return { ...base, ok: false, kind: "notfound" };
|
||||
}
|
||||
if (/unable to connect to the server|dial tcp|connection refused|no such host/.test(s)) {
|
||||
return { ...base, ok: false, kind: "network" };
|
||||
}
|
||||
return { ...base, ok: false, kind: "other" };
|
||||
}
|
||||
|
||||
export interface RunKubectlOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function runKubectl(
|
||||
pi: Pick<ExtensionAPI, "exec">,
|
||||
env: K8sEnv,
|
||||
verb: string,
|
||||
verbArgs: string[],
|
||||
opts?: RunKubectlOptions,
|
||||
): Promise<KubectlResult> {
|
||||
const args = buildKubectlArgs(env, verb, verbArgs);
|
||||
const exec = await pi.exec("kubectl", args, {
|
||||
signal: opts?.signal,
|
||||
timeout: opts?.timeoutMs ?? 30_000,
|
||||
});
|
||||
return classifyKubectlResult(exec, verb);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { buildDescribeArgs, K8sDescribeSchema } from "./tools/describe.js";
|
||||
import { buildEventsArgs, K8sEventsSchema } from "./tools/events.js";
|
||||
import { buildExplainArgs, K8sExplainSchema } from "./tools/explain.js";
|
||||
import { buildGetArgs, K8sGetSchema } from "./tools/get.js";
|
||||
import { buildLogsArgs, K8sLogsSchema } from "./tools/logs.js";
|
||||
import { buildTopArgs, K8sTopSchema } from "./tools/top.js";
|
||||
import { formatContext, K8sContextSchema } from "./tools/context.js";
|
||||
import { type K8sEnv, readK8sEnv, runKubectl } from "./exec.js";
|
||||
import { type DiagRunner } from "./diag.js";
|
||||
import { readK8sConfig } from "./config.js";
|
||||
import { openK8sPanel } from "./panel.js";
|
||||
|
||||
const NOT_CONFIGURED =
|
||||
"k8s credentials not present in this sandbox. Set KUBE_APISERVER and KUBE_TOKEN by attaching the k8s OpenShell provider, or set the cluster URL via /k8s.";
|
||||
|
||||
function resolveK8sEnv(): ReturnType<typeof readK8sEnv> {
|
||||
const envOnly = readK8sEnv(process.env);
|
||||
if (envOnly.ok) return envOnly;
|
||||
if (!process.env.KUBE_APISERVER) {
|
||||
const cfg = readK8sConfig();
|
||||
if (cfg.apiServer) {
|
||||
return readK8sEnv({
|
||||
...process.env,
|
||||
KUBE_APISERVER: cfg.apiServer,
|
||||
});
|
||||
}
|
||||
}
|
||||
return envOnly;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let currentEnv: K8sEnv | null = null;
|
||||
|
||||
function refreshEnv(): K8sEnv | null {
|
||||
const result = resolveK8sEnv();
|
||||
currentEnv = result.ok ? result.env : null;
|
||||
return currentEnv;
|
||||
}
|
||||
|
||||
function buildRunner(env: K8sEnv | null): DiagRunner | null {
|
||||
if (!env) return null;
|
||||
return (verb, args) => runKubectl(pi, env, verb, args, { timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const result = resolveK8sEnv();
|
||||
if (result.ok) {
|
||||
currentEnv = result.env;
|
||||
if (result.env.insecureSkipTls && ctx.hasUI) {
|
||||
ctx.ui.notify(
|
||||
"PI_K8S_INSECURE_SKIP_TLS=1 — kubectl will skip TLS verification",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
currentEnv = null;
|
||||
if (process.env.KUBE_APISERVER && ctx.hasUI) {
|
||||
ctx.ui.notify(`k8s: ${result.error}`, "warning");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.registerCommand("k8s", {
|
||||
description: "Open the read-only k8s diagnostics panel",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("/k8s needs an interactive session", "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openK8sPanel(ctx, refreshEnv, buildRunner);
|
||||
} catch (err) {
|
||||
ctx.ui.notify(`/k8s error: ${(err as Error).message}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_get",
|
||||
label: "k8s: get",
|
||||
description:
|
||||
"List or fetch Kubernetes resources read-only. Default lists across all namespaces (-A); narrow with namespace, selector, or name.",
|
||||
parameters: K8sGetSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "get", buildGetArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
const ns = args.namespace ? ` -n ${args.namespace}` : " -A";
|
||||
const name = args.name ? `/${args.name}` : "";
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s get ")) +
|
||||
theme.fg("accent", `${args.kind}${name}`) +
|
||||
theme.fg("muted", ns),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_describe",
|
||||
label: "k8s: describe",
|
||||
description: "Show detailed state for a specific resource.",
|
||||
parameters: K8sDescribeSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "describe", buildDescribeArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
const ns = args.namespace ? ` -n ${args.namespace}` : "";
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s describe ")) +
|
||||
theme.fg("accent", `${args.kind}/${args.name}`) +
|
||||
theme.fg("muted", ns),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_logs",
|
||||
label: "k8s: logs",
|
||||
description:
|
||||
"Fetch logs for a pod. Use tail to cap output (default 200, max 2000); previous=true for the prior crash.",
|
||||
parameters: K8sLogsSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "logs", buildLogsArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
const ns = args.namespace ? ` -n ${args.namespace}` : "";
|
||||
const tail = args.tail ? ` --tail=${args.tail}` : "";
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s logs ")) +
|
||||
theme.fg("accent", args.pod) +
|
||||
theme.fg("muted", `${ns}${tail}`),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_events",
|
||||
label: "k8s: events",
|
||||
description: "Recent cluster events from the last hour (default), sorted by lastTimestamp.",
|
||||
parameters: K8sEventsSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "events", buildEventsArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
const ns = args.namespace ? ` -n ${args.namespace}` : " -A";
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s events")) +
|
||||
theme.fg("muted", ns),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_top",
|
||||
label: "k8s: top",
|
||||
description: "Show resource usage (CPU/mem) for pods or nodes. Requires metrics-server.",
|
||||
parameters: K8sTopSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "top", buildTopArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
const ns = args.namespace ? ` -n ${args.namespace}` : args.kind === "pod" ? " -A" : "";
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s top ")) +
|
||||
theme.fg("accent", args.kind) +
|
||||
theme.fg("muted", ns),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_explain",
|
||||
label: "k8s: explain",
|
||||
description: "Show the schema for a resource (or a path inside one).",
|
||||
parameters: K8sExplainSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const res = await runKubectl(pi, currentEnv, "explain", buildExplainArgs(params), { signal });
|
||||
const text = res.ok
|
||||
? res.stdout.trim()
|
||||
: res.stderr.trim() + (res.stdout.trim() ? `\n${res.stdout.trim()}` : "");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { ok: res.ok, kind: res.kind, exitCode: res.exitCode, verb: res.verb },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s explain ")) +
|
||||
theme.fg("accent", args.resource),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "k8s_context",
|
||||
label: "k8s: context",
|
||||
description:
|
||||
"Report the current cluster connection (server + identity). No context switching: each sandbox holds one set of k8s credentials.",
|
||||
parameters: K8sContextSchema,
|
||||
|
||||
async execute(_id, params, signal, _onUpdate, _ctx) {
|
||||
if (!currentEnv) {
|
||||
return {
|
||||
content: [{ type: "text", text: NOT_CONFIGURED }],
|
||||
details: { ok: false, kind: "other", exitCode: -1, verb: "(none)" },
|
||||
};
|
||||
}
|
||||
const whoamiRes = await runKubectl(
|
||||
pi,
|
||||
currentEnv,
|
||||
"auth",
|
||||
["whoami", "-o", "jsonpath={.status.userInfo.username}"],
|
||||
{ signal },
|
||||
);
|
||||
const whoami = whoamiRes.kind === "ok" ? whoamiRes.stdout : "";
|
||||
return {
|
||||
content: [{ type: "text", text: formatContext(params.action, currentEnv.apiServer, whoami) }],
|
||||
details: { ok: true, kind: "ok", exitCode: 0, verb: "context" },
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme, _context) {
|
||||
return new Text(
|
||||
theme.fg("toolTitle", theme.bold("k8s context ")) +
|
||||
theme.fg("muted", args.action),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
const d = result.details as { ok: boolean; kind: string } | undefined;
|
||||
const marker = d?.ok ? theme.fg("success", "✓") : theme.fg("warning", `✗ ${d?.kind ?? "err"}`);
|
||||
return new Text(marker, 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import type { K8sEnv } from "./exec.js";
|
||||
import { type DiagRunner, type DiagnosticsReport, runDiagnostics } from "./diag.js";
|
||||
import { readK8sConfig, writeK8sConfig } from "./config.js";
|
||||
|
||||
type PanelAction = { kind: "close" } | { kind: "refresh" } | { kind: "edit-url" };
|
||||
|
||||
function renderNoEnv(
|
||||
ctx: ExtensionCommandContext,
|
||||
currentApiServer: string | undefined,
|
||||
): Promise<PanelAction> {
|
||||
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
|
||||
function handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) {
|
||||
done({ kind: "close" });
|
||||
return;
|
||||
}
|
||||
if (data === "e") done({ kind: "edit-url" });
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
add(theme.fg("text", " Kubernetes Diagnostics"));
|
||||
lines.push("");
|
||||
add(theme.fg("warning", " No k8s credentials in this sandbox."));
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " KUBE_TOKEN must come from an OpenShell provider:"));
|
||||
add(theme.fg("dim", " openshell provider create --name k8s-read --type generic \\"));
|
||||
add(theme.fg("dim", ' --credential KUBE_TOKEN="$(cat /path/to/sa.token)"'));
|
||||
lines.push("");
|
||||
add(
|
||||
theme.fg("dim", " Cluster URL: ") +
|
||||
theme.fg("text", currentApiServer ?? "(unset)"),
|
||||
);
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " e edit cluster URL • Esc / Enter close"));
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
return lines;
|
||||
}
|
||||
|
||||
return { render, invalidate: () => {}, handleInput };
|
||||
});
|
||||
}
|
||||
|
||||
function renderReport(
|
||||
ctx: ExtensionCommandContext,
|
||||
report: DiagnosticsReport,
|
||||
): Promise<PanelAction> {
|
||||
return ctx.ui.custom<PanelAction>((_tui, theme, _kb, done) => {
|
||||
let cached: string[] | undefined;
|
||||
|
||||
function handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
done({ kind: "close" });
|
||||
return;
|
||||
}
|
||||
if (data === "r") {
|
||||
done({ kind: "refresh" });
|
||||
return;
|
||||
}
|
||||
if (data === "e") done({ kind: "edit-url" });
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cached) return cached;
|
||||
const lines: string[] = [];
|
||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
add(theme.fg("text", " Kubernetes Diagnostics"));
|
||||
lines.push("");
|
||||
|
||||
const labelW = 11;
|
||||
const serverLabel = theme.fg("muted", "Server:".padEnd(labelW));
|
||||
add(" " + serverLabel + theme.fg("text", report.server));
|
||||
|
||||
let reachLine: string;
|
||||
if (report.reachable) {
|
||||
const version = report.serverVersion ? ` ${report.serverVersion}` : "";
|
||||
reachLine = theme.fg("success", `✓${version}`);
|
||||
} else {
|
||||
const err = report.error ? ` ${report.error}` : "";
|
||||
reachLine = theme.fg("warning", `✗${err}`);
|
||||
}
|
||||
add(" " + theme.fg("muted", "Reachable:".padEnd(labelW)) + reachLine);
|
||||
|
||||
lines.push("");
|
||||
add(" " + theme.fg("muted", "Identity:".padEnd(labelW)) + theme.fg("text", report.identity));
|
||||
lines.push("");
|
||||
|
||||
add(theme.fg("dim", " Permissions (read paths)"));
|
||||
for (const row of report.allowed) {
|
||||
const check = row.allowed
|
||||
? theme.fg("success", "✓")
|
||||
: theme.fg("warning", "✗");
|
||||
add(" " + theme.fg("text", row.label.padEnd(34)) + check);
|
||||
}
|
||||
|
||||
if (report.denied.length > 0) {
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " Safety (should be denied)"));
|
||||
for (const row of report.denied) {
|
||||
let status: string;
|
||||
if (!row.allowed) {
|
||||
status = theme.fg("success", "✗ (good)");
|
||||
} else {
|
||||
status = theme.fg("warning", "✓ allowed — escalation risk!");
|
||||
}
|
||||
add(" " + theme.fg("text", row.label.padEnd(34)) + status);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " r refresh • e edit cluster URL • Esc close"));
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
cached = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cached = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function promptApiServer(
|
||||
ctx: ExtensionCommandContext,
|
||||
current: string | undefined,
|
||||
): Promise<void> {
|
||||
const entered = await ctx.ui.input(
|
||||
"Cluster API server URL (https://host:port)",
|
||||
current ?? "",
|
||||
);
|
||||
if (entered === undefined) return;
|
||||
const trimmed = entered.trim();
|
||||
if (trimmed === "") {
|
||||
ctx.ui.notify("Cluster URL cannot be empty — unchanged", "warning");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
ctx.ui.notify(`Cluster URL must be http(s); got ${parsed.protocol}`, "warning");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ctx.ui.notify(`Not a valid URL: ${trimmed}`, "warning");
|
||||
return;
|
||||
}
|
||||
writeK8sConfig({ version: 1, apiServer: trimmed });
|
||||
ctx.ui.notify(`Saved cluster URL to k8s.json`, "info");
|
||||
}
|
||||
|
||||
export async function openK8sPanel(
|
||||
ctx: ExtensionCommandContext,
|
||||
refreshEnv: () => K8sEnv | null,
|
||||
buildRunner: (env: K8sEnv | null) => DiagRunner | null,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const env = refreshEnv();
|
||||
const runner = buildRunner(env);
|
||||
let action: PanelAction;
|
||||
if (!env || !runner) {
|
||||
const fileUrl = readK8sConfig().apiServer;
|
||||
action = await renderNoEnv(ctx, process.env.KUBE_APISERVER ?? fileUrl);
|
||||
} else {
|
||||
const report = await runDiagnostics(runner, env);
|
||||
action = await renderReport(ctx, report);
|
||||
}
|
||||
if (action.kind === "close") break;
|
||||
if (action.kind === "edit-url") {
|
||||
const current =
|
||||
process.env.KUBE_APISERVER ?? readK8sConfig().apiServer ?? "";
|
||||
await promptApiServer(ctx, current);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatContext } from "./context.js";
|
||||
|
||||
describe("formatContext", () => {
|
||||
it('action="current" formats server + identity', () => {
|
||||
const out = formatContext(
|
||||
"current",
|
||||
"https://api.cluster.example:6443",
|
||||
"system:serviceaccount:pi/pi-readonly",
|
||||
);
|
||||
expect(out).toContain("https://api.cluster.example:6443");
|
||||
expect(out).toContain("system:serviceaccount:pi/pi-readonly");
|
||||
});
|
||||
|
||||
it('action="list" notes there is only one context', () => {
|
||||
const out = formatContext(
|
||||
"list",
|
||||
"https://api.cluster.example:6443",
|
||||
"system:serviceaccount:pi/pi-readonly",
|
||||
);
|
||||
expect(out.toLowerCase()).toContain("only one context");
|
||||
});
|
||||
|
||||
it("handles unknown identity gracefully", () => {
|
||||
const out = formatContext("current", "https://x", "");
|
||||
expect(out).toContain("https://x");
|
||||
expect(out.toLowerCase()).toContain("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sContextSchema = Type.Object({
|
||||
action: StringEnum(["current", "list"] as const, {
|
||||
description: "What to report about the current cluster connection.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type K8sContextParams = Static<typeof K8sContextSchema>;
|
||||
|
||||
export function formatContext(
|
||||
action: "current" | "list",
|
||||
server: string,
|
||||
whoami: string,
|
||||
): string {
|
||||
const identity = whoami.trim() || "(unknown identity)";
|
||||
const current = `${server} as ${identity}`;
|
||||
if (action === "current") return current;
|
||||
return `${current}\n(only one context: this sandbox)`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDescribeArgs } from "./describe.js";
|
||||
|
||||
describe("buildDescribeArgs", () => {
|
||||
it("kind + name only, no namespace (kubectl defaults to current ns)", () => {
|
||||
expect(buildDescribeArgs({ kind: "pod", name: "api" })).toEqual([
|
||||
"pod",
|
||||
"api",
|
||||
]);
|
||||
});
|
||||
|
||||
it("with namespace adds -n", () => {
|
||||
expect(
|
||||
buildDescribeArgs({ kind: "pod", name: "api", namespace: "prod" }),
|
||||
).toEqual(["pod", "api", "-n", "prod"]);
|
||||
});
|
||||
|
||||
it("cluster-scoped resource (node) needs no namespace", () => {
|
||||
expect(buildDescribeArgs({ kind: "node", name: "n01" })).toEqual([
|
||||
"node",
|
||||
"n01",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sDescribeSchema = Type.Object({
|
||||
kind: Type.String({ description: "Resource kind (pod, deployment, node, ...)." }),
|
||||
name: Type.String({ description: "Specific resource name." }),
|
||||
namespace: Type.Optional(
|
||||
Type.String({
|
||||
description: "Namespace. Required for namespaced resources unless they live in 'default'.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type K8sDescribeParams = Static<typeof K8sDescribeSchema>;
|
||||
|
||||
export function buildDescribeArgs(p: K8sDescribeParams): string[] {
|
||||
const args = [p.kind, p.name];
|
||||
if (p.namespace) args.push("-n", p.namespace);
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildEventsArgs } from "./events.js";
|
||||
|
||||
describe("buildEventsArgs", () => {
|
||||
it("no args → events -A --since=1h", () => {
|
||||
expect(buildEventsArgs({})).toEqual(["-A", "--since=1h", "--sort-by=.lastTimestamp"]);
|
||||
});
|
||||
|
||||
it("namespace narrows scope", () => {
|
||||
expect(buildEventsArgs({ namespace: "prod" })).toEqual([
|
||||
"-n",
|
||||
"prod",
|
||||
"--since=1h",
|
||||
"--sort-by=.lastTimestamp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("explicit since overrides default", () => {
|
||||
expect(buildEventsArgs({ since: "10m" })).toEqual([
|
||||
"-A",
|
||||
"--since=10m",
|
||||
"--sort-by=.lastTimestamp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sEventsSchema = Type.Object({
|
||||
namespace: Type.Optional(
|
||||
Type.String({ description: "Namespace; omit for -A." }),
|
||||
),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: 'Duration window (default "1h").' }),
|
||||
),
|
||||
});
|
||||
|
||||
export type K8sEventsParams = Static<typeof K8sEventsSchema>;
|
||||
|
||||
export function buildEventsArgs(p: K8sEventsParams): string[] {
|
||||
const args: string[] = [];
|
||||
if (p.namespace) {
|
||||
args.push("-n", p.namespace);
|
||||
} else {
|
||||
args.push("-A");
|
||||
}
|
||||
args.push(`--since=${p.since ?? "1h"}`);
|
||||
args.push("--sort-by=.lastTimestamp");
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExplainArgs } from "./explain.js";
|
||||
|
||||
describe("buildExplainArgs", () => {
|
||||
it("passes the resource string through", () => {
|
||||
expect(buildExplainArgs({ resource: "deployment.spec.strategy" })).toEqual([
|
||||
"deployment.spec.strategy",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sExplainSchema = Type.Object({
|
||||
resource: Type.String({
|
||||
description: 'Resource (and optional path), e.g. "pod" or "deployment.spec.strategy".',
|
||||
}),
|
||||
});
|
||||
|
||||
export type K8sExplainParams = Static<typeof K8sExplainSchema>;
|
||||
|
||||
export function buildExplainArgs(p: K8sExplainParams): string[] {
|
||||
return [p.resource];
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGetArgs } from "./get.js";
|
||||
|
||||
describe("buildGetArgs", () => {
|
||||
it("kind only → -A and default -o wide", () => {
|
||||
expect(buildGetArgs({ kind: "pod" })).toEqual([
|
||||
"pod",
|
||||
"-A",
|
||||
"-o",
|
||||
"wide",
|
||||
]);
|
||||
});
|
||||
|
||||
it("namespace narrows to -n", () => {
|
||||
expect(buildGetArgs({ kind: "pod", namespace: "prod" })).toEqual([
|
||||
"pod",
|
||||
"-n",
|
||||
"prod",
|
||||
"-o",
|
||||
"wide",
|
||||
]);
|
||||
});
|
||||
|
||||
it("all=true overrides namespace back to -A", () => {
|
||||
expect(buildGetArgs({ kind: "pod", namespace: "prod", all: true })).toEqual([
|
||||
"pod",
|
||||
"-A",
|
||||
"-o",
|
||||
"wide",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selector adds -l", () => {
|
||||
expect(buildGetArgs({ kind: "pod", selector: "app=api" })).toEqual([
|
||||
"pod",
|
||||
"-A",
|
||||
"-l",
|
||||
"app=api",
|
||||
"-o",
|
||||
"wide",
|
||||
]);
|
||||
});
|
||||
|
||||
it("name positions positionally and defaults output to yaml", () => {
|
||||
expect(
|
||||
buildGetArgs({ kind: "pod", name: "api-7f9b", namespace: "prod" }),
|
||||
).toEqual(["pod", "api-7f9b", "-n", "prod", "-o", "yaml"]);
|
||||
});
|
||||
|
||||
it("name wins over selector (selector is dropped)", () => {
|
||||
expect(
|
||||
buildGetArgs({ kind: "pod", name: "api-7f9b", selector: "app=api" }),
|
||||
).toEqual(["pod", "api-7f9b", "-A", "-o", "yaml"]);
|
||||
});
|
||||
|
||||
it("explicit output overrides defaults", () => {
|
||||
expect(
|
||||
buildGetArgs({ kind: "pod", output: "json" }),
|
||||
).toEqual(["pod", "-A", "-o", "json"]);
|
||||
});
|
||||
|
||||
it("output=name works for list", () => {
|
||||
expect(
|
||||
buildGetArgs({ kind: "pod", output: "name" }),
|
||||
).toEqual(["pod", "-A", "-o", "name"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sGetSchema = Type.Object({
|
||||
kind: Type.String({
|
||||
description: "Resource kind (e.g. pod, deployment, node, or a CRD).",
|
||||
}),
|
||||
namespace: Type.Optional(
|
||||
Type.String({ description: "Namespace; omit for -A across all namespaces." }),
|
||||
),
|
||||
selector: Type.Optional(
|
||||
Type.String({ description: "Label selector (e.g. app=api). Ignored when name is set." }),
|
||||
),
|
||||
name: Type.Optional(
|
||||
Type.String({ description: "Specific resource name. Wins over selector." }),
|
||||
),
|
||||
all: Type.Optional(
|
||||
Type.Boolean({ description: "Force -A even when namespace is given." }),
|
||||
),
|
||||
output: Type.Optional(
|
||||
StringEnum(["name", "wide", "yaml", "json"] as const, {
|
||||
description: "Output format. Default 'wide' for lists, 'yaml' when name is set.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type K8sGetParams = Static<typeof K8sGetSchema>;
|
||||
|
||||
export function buildGetArgs(p: K8sGetParams): string[] {
|
||||
const args: string[] = [p.kind];
|
||||
if (p.name) args.push(p.name);
|
||||
const useAll = p.all || !p.namespace;
|
||||
if (useAll) {
|
||||
args.push("-A");
|
||||
} else {
|
||||
args.push("-n", p.namespace!);
|
||||
}
|
||||
if (!p.name && p.selector) args.push("-l", p.selector);
|
||||
const output = p.output ?? (p.name ? "yaml" : "wide");
|
||||
args.push("-o", output);
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLogsArgs } from "./logs.js";
|
||||
|
||||
describe("buildLogsArgs", () => {
|
||||
it("pod only → default tail=200", () => {
|
||||
expect(buildLogsArgs({ pod: "api-7f9b" })).toEqual([
|
||||
"api-7f9b",
|
||||
"--tail=200",
|
||||
]);
|
||||
});
|
||||
|
||||
it("container adds -c", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", container: "app" }),
|
||||
).toEqual(["api-7f9b", "-c", "app", "--tail=200"]);
|
||||
});
|
||||
|
||||
it("namespace adds -n", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", namespace: "prod" }),
|
||||
).toEqual(["api-7f9b", "-n", "prod", "--tail=200"]);
|
||||
});
|
||||
|
||||
it("explicit tail is honored", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", tail: 500 }),
|
||||
).toEqual(["api-7f9b", "--tail=500"]);
|
||||
});
|
||||
|
||||
it("tail > 2000 is clamped to 2000", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", tail: 5000 }),
|
||||
).toEqual(["api-7f9b", "--tail=2000"]);
|
||||
});
|
||||
|
||||
it("tail = 0 is clamped to 1 (kubectl rejects 0)", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", tail: 0 }),
|
||||
).toEqual(["api-7f9b", "--tail=1"]);
|
||||
});
|
||||
|
||||
it("since adds --since", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", since: "10m" }),
|
||||
).toEqual(["api-7f9b", "--tail=200", "--since=10m"]);
|
||||
});
|
||||
|
||||
it("previous adds --previous", () => {
|
||||
expect(
|
||||
buildLogsArgs({ pod: "api-7f9b", previous: true }),
|
||||
).toEqual(["api-7f9b", "--tail=200", "--previous"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sLogsSchema = Type.Object({
|
||||
pod: Type.String({ description: "Pod name." }),
|
||||
container: Type.Optional(
|
||||
Type.String({ description: "Container within the pod (omit when pod has one container)." }),
|
||||
),
|
||||
namespace: Type.Optional(Type.String({ description: "Pod's namespace." })),
|
||||
tail: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of recent log lines. Default 200, clamped to [1, 2000].",
|
||||
}),
|
||||
),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: 'Duration (e.g. "10m") or RFC3339 timestamp.' }),
|
||||
),
|
||||
previous: Type.Optional(
|
||||
Type.Boolean({ description: "Read logs from the previously crashed container instance." }),
|
||||
),
|
||||
});
|
||||
|
||||
export type K8sLogsParams = Static<typeof K8sLogsSchema>;
|
||||
|
||||
export function buildLogsArgs(p: K8sLogsParams): string[] {
|
||||
const args = [p.pod];
|
||||
if (p.container) args.push("-c", p.container);
|
||||
if (p.namespace) args.push("-n", p.namespace);
|
||||
const requested = p.tail ?? 200;
|
||||
const tail = Math.max(1, Math.min(2000, requested));
|
||||
args.push(`--tail=${tail}`);
|
||||
if (p.since) args.push(`--since=${p.since}`);
|
||||
if (p.previous) args.push("--previous");
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildTopArgs } from "./top.js";
|
||||
|
||||
describe("buildTopArgs", () => {
|
||||
it("kind=pod with -A by default", () => {
|
||||
expect(buildTopArgs({ kind: "pod" })).toEqual(["pod", "-A"]);
|
||||
});
|
||||
|
||||
it("kind=pod with namespace", () => {
|
||||
expect(buildTopArgs({ kind: "pod", namespace: "prod" })).toEqual([
|
||||
"pod",
|
||||
"-n",
|
||||
"prod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("kind=node ignores namespace (cluster-scoped)", () => {
|
||||
expect(buildTopArgs({ kind: "node" })).toEqual(["node"]);
|
||||
expect(buildTopArgs({ kind: "node", namespace: "prod" })).toEqual([
|
||||
"node",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import { type Static, Type } from "typebox";
|
||||
|
||||
export const K8sTopSchema = Type.Object({
|
||||
kind: StringEnum(["pod", "node"] as const, {
|
||||
description: "What to show resource usage for.",
|
||||
}),
|
||||
namespace: Type.Optional(
|
||||
Type.String({ description: "For kind=pod only; ignored for nodes." }),
|
||||
),
|
||||
});
|
||||
|
||||
export type K8sTopParams = Static<typeof K8sTopSchema>;
|
||||
|
||||
export function buildTopArgs(p: K8sTopParams): string[] {
|
||||
if (p.kind === "node") return ["node"];
|
||||
const args = ["pod"];
|
||||
if (p.namespace) {
|
||||
args.push("-n", p.namespace);
|
||||
} else {
|
||||
args.push("-A");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -49,4 +49,29 @@ export const BUNDLED_MODES: ModeFileFields[] = [
|
||||
"Don't modify files. Return a compressed brief — quote, don't allude.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
name: "k8s",
|
||||
description: "Read-only Kubernetes troubleshooting",
|
||||
tools: [
|
||||
"read",
|
||||
"ls",
|
||||
"grep",
|
||||
"find",
|
||||
"web_search",
|
||||
"memory_read",
|
||||
"k8s_get",
|
||||
"k8s_describe",
|
||||
"k8s_logs",
|
||||
"k8s_events",
|
||||
"k8s_top",
|
||||
"k8s_explain",
|
||||
"k8s_context",
|
||||
],
|
||||
prompt: [
|
||||
"You are in k8s troubleshooting mode.",
|
||||
"Investigate the cluster read-only via the k8s_* tools.",
|
||||
"Start broad (events, then pods across namespaces), narrow once you have evidence.",
|
||||
"Don't propose write actions — you cannot perform them. Report findings and suggest the human's next move.",
|
||||
].join(" "),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Status Line Extension
|
||||
*
|
||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||
* Shows turn progress with themed colors.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let turnCount = 0;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (_event, ctx) => {
|
||||
turnCount++;
|
||||
const theme = ctx.ui.theme;
|
||||
const spinner = theme.fg("accent", "●");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||
ctx.ui.setStatus("status-demo", spinner + text);
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
const check = theme.fg("success", "✓");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||
ctx.ui.setStatus("status-demo", check + text);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Baseline OpenShell sandbox policy for the pi-customizations container.
|
||||
#
|
||||
# Pi-specific provider endpoints (e.g. the k8s read-only API) come from
|
||||
# provider profiles, which auto-emit network_policies entries when the
|
||||
# matching provider is attached. See openshell-k8s-profile.yaml.
|
||||
#
|
||||
# `filesystem_policy` / `landlock` / `process` mirror the OpenShell
|
||||
# quickstart defaults; sync if those change upstream.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox create --policy openshell-policy.yaml ...
|
||||
|
||||
version: 1
|
||||
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
|
||||
read_write: [/sandbox, /tmp, /dev/null]
|
||||
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
|
||||
process:
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
Reference in New Issue
Block a user