Compare commits

..

2 Commits

Author SHA1 Message Date
Chris Farhood 127eab89e7 fix: correct fs mock with vi.hoisted for proper per-test reset
The vi.mock("node:fs/promises") factory previously used a closure variable
that accumulated across tests despite vi.clearAllMocks(). Switched to
vi.hoisted() with an explicit resetFsMocks() called in beforeEach() so
the read offset counter is properly reset between tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:38:57 -04:00
Chris Farhood 78d655eeb6 feat: replace K8s log streaming with PVC filesystem tailing
- Replaced streamPodLogs / streamPodLogsOnce / readPodLogs / waitForPodTermination
  with tailPodLogFile() that polls a shared PVC file path with adaptive cadence
  (250ms active, 1000ms idle after 5 consecutive empty polls)
- Added buildPodLogPath() export and podLogPath to JobBuildResult
- Added assertSafePathComponent with [a-zA-Z0-9-:] allowance for UUIDs
- Updated Job manifest to tee stdout to /paperclip/instances/default/run-logs/<companyId>/<agentId>/<runId>.pod.ndjson
- Added hasOutOfProcessLiveness: true to createServerAdapter (cast required)
- Deleted log-dedup.ts and log-dedup.test.ts entirely
- Removed all LogLineDedupFilter, Writable, and LOG_STREAM_* constants
- Removed completionResult.status workaround (completionWithGrace returns directly)
- Test infrastructure: mocked node:fs/promises to prevent unmocked fs.stat hangs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:56:58 -04:00
11 changed files with 78 additions and 328 deletions
-2
View File
@@ -1,7 +1,5 @@
# OpenCode (Kubernetes) Paperclip Adapter Plugin
> **⚠️ Abandoned** — This adapter is no longer maintained. Please use the new sandbox plugin instead: **[farhoodlabs/paperclip-plugin-k8s](https://github.com/farhoodlabs/paperclip-plugin-k8s)** (`@farhoodlabs/paperclip-plugin-k8s` on npm).
Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
## Features
+7 -7
View File
@@ -1,26 +1,26 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3",
"version": "0.1.38",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3",
"version": "0.1.38",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@paperclipai/adapter-utils": "^2026.428.0",
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.428.0"
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -223,9 +223,9 @@
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"version": "2026.415.0-canary.7",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz",
"integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==",
"dev": true,
"license": "MIT"
},
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.2.3",
"version": "0.1.38",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
@@ -10,15 +10,14 @@
"exports": {
".": "./dist/index.js",
"./server": "./dist/server/index.js",
"./ui-parser": "./dist/ui-parser/ui-parser.js",
"./ui-parser": "./dist/ui-parser.js",
"./cli": "./dist/cli/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json && npm run build:ui-parser",
"build:ui-parser": "tsc -p tsconfig.ui-parser.json && node -e \"require('node:fs').writeFileSync('dist/ui-parser/package.json', '{\\\"type\\\":\\\"commonjs\\\"}\\n')\"",
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"test": "vitest run",
@@ -29,10 +28,10 @@
"picocolors": "^1.1.1"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.428.0"
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
},
"devDependencies": {
"@paperclipai/adapter-utils": "^2026.428.0",
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
+1
View File
@@ -64,3 +64,4 @@ Notes:
`;
export { createServerAdapter } from "./server/index.js";
export { parseStdoutLine } from "./ui-parser.js";
+27 -47
View File
@@ -4,47 +4,33 @@ import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
import { buildJobManifest, buildPodLogPath } from "./job-manifest.js";
// Mock node:fs/promises so tailPodLogFile (used by execute()) reads a
// configurable JSONL payload and returns. Individual tests override the
// payload via setMockJsonl(...) before calling execute().
const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => {
const HAPPY = [
JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }),
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
].join("\n");
let payload = HAPPY;
let buffer = Buffer.from(payload);
// Mock node:fs/promises to prevent tailPodLogFile (used by execute()) from
// hanging on unmocked fs.stat calls in test environment.
// vi.hoisted creates shared module-level state; beforeEach resets it so every
// test gets a clean first-read-success.
const { readMock, resetFsMocks } = vi.hoisted(() => {
let readOffset = 0;
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
return {
readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => {
if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf };
const remaining = buffer.byteLength - readOffset;
const toRead = Math.min(len, remaining);
buffer.copy(buf, off, readOffset, readOffset + toRead);
readOffset += toRead;
return { bytesRead: toRead, buffer: buf };
readMock: vi.fn().mockImplementation(async () => {
if (readOffset === 0) {
readOffset = 17;
return { bytesRead: 17, buffer: Buffer.from('{"type":"text"}\n') };
}
return { bytesRead: 0, buffer: Buffer.alloc(0) };
}),
statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
resetFsMocks: () => { apply(HAPPY); },
setMockJsonl: (jsonl: string) => { apply(jsonl); },
resetFsMocks: () => { readOffset = 0; },
};
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
stat: statMock,
open: vi.fn().mockResolvedValue({
stat: fhStatMock,
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("node:fs/promises", () => ({
stat: vi.fn().mockResolvedValue({ size: 17 }),
open: vi.fn().mockResolvedValue({
stat: vi.fn().mockResolvedValue({ size: 17 }),
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
@@ -57,7 +43,7 @@ vi.mock("./k8s-client.js", () => ({
vi.mock("./job-manifest.js", () => ({
buildJobManifest: vi.fn(),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
@@ -183,7 +169,7 @@ beforeEach(() => {
prompt: "Test prompt",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const batchApi = makeBatchApi();
@@ -604,7 +590,6 @@ describe("execute — happy path", () => {
describe("execute — session unavailable (reattach classification)", () => {
it("returns clearSession=true and session_unavailable code for unknown session error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Unknown session ses_xxx" } }));
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -616,7 +601,6 @@ describe("execute — session unavailable (reattach classification)", () => {
});
it("returns clearSession=true for 'session not found' error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Session ses_xxx not found" } }));
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -688,7 +672,6 @@ describe("execute — exit code handling", () => {
});
it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "something went wrong" } }));
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -752,7 +735,6 @@ describe("execute — llm_api_error signal", () => {
it("returns llm_api_error when session exists but LLM produced no output tokens", async () => {
// JSONL has a sessionID but no step_finish tokens and no text messages
const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } });
setMockJsonl(emptyOutputJsonl);
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -775,7 +757,6 @@ describe("execute — llm_api_error signal", () => {
const errorJsonl = [
JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }),
].join("\n");
setMockJsonl(errorJsonl);
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -958,7 +939,7 @@ describe("execute — large-prompt Secret path", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
}
@@ -1290,7 +1271,7 @@ describe("execute — large-prompt Secret create failure", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi();
@@ -1324,7 +1305,6 @@ describe("execute — step limit detection", () => {
JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }),
JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }),
].join("\n");
setMockJsonl(STEP_LIMIT_JSONL);
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -1527,10 +1507,10 @@ describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
prompt: "p",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
}),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
+33 -49
View File
@@ -269,45 +269,40 @@ export async function tailPodLogFile(
let idleCount = 0;
const accumulator: string[] = [];
const drain = async (): Promise<boolean> => {
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
return false;
}
if (size <= offset) return false;
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
return bytesRead > 0;
};
try {
while (!stopSignal.stopped) {
const grew = await drain();
if (grew) {
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
await new Promise((r) => setTimeout(r, pollMs));
if (stopSignal.stopped) break;
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
break;
}
if (size > offset) {
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
idleCount = 0;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
} else {
idleCount++;
}
if (stopSignal.stopped) break;
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
await new Promise((r) => setTimeout(r, pollMs));
}
// Final drain after stopSignal — pick up any bytes written between the
// last read and the job reaching terminal state.
while (await drain()) { /* read until no more growth */ }
// Final drain on stop
if (pending) {
await onLog("stdout", pending + "\n");
accumulator.push(pending + "\n");
@@ -467,24 +462,13 @@ async function streamAndAwaitJob(
return onLog(stream, chunk);
};
// Run the file tail and the job-completion poll in parallel so that the
// tail loop has a way to stop: when waitForJobCompletion resolves it sets
// stopSignal.stopped, which lets tailPodLogFile drain and return.
// No completionWithGrace wrapper here — wrapping a long-running job poll
// in a 30s grace turns the grace into a hard ceiling and kills runs
// prematurely with "Timed out after 0s" when timeoutSec is 0 (no timeout).
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath)
.then((r) => { stopSignal.stopped = true; return r; });
const [tailSettled, completionSettled] = await Promise.allSettled([
tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal }),
completionPromise,
]);
stdout = tailSettled.status === "fulfilled" ? tailSettled.value : "";
if (completionSettled.status === "rejected") {
stopSignal.stopped = true;
throw completionSettled.reason;
}
const completion = completionSettled.value;
const tailResult = await tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal });
stdout = tailResult;
// Wait for job completion (may already be done by the time we read the file)
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath);
const completionGraced = completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS);
const completion = await completionGraced;
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
+2 -3
View File
@@ -359,9 +359,8 @@ describe("init container is unchanged by agentDbClaimName", () => {
it("does not add extra env vars to init container for dedicated PVC mode", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
// init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var
expect(initCmd?.[2]).not.toContain("mkdir");
expect(initCmd?.[2]).toContain("/tmp/prompt/prompt.txt");
// mkdir is added for log directory but OPENCODE_DB_PATH env var is NOT added
expect(initCmd?.[2]).toContain("mkdir");
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
});
+3 -3
View File
@@ -25,7 +25,7 @@ function assertSafePathComponent(field: string, value: string): void {
}
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
}
export interface JobBuildInput {
@@ -461,14 +461,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
imagePullPolicy: "IfNotPresent",
...(input.promptSecretName
? {
command: ["sh", "-c", `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
],
}
: {
command: ["sh", "-c", `printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
}),
-195
View File
@@ -1,195 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
import { testEnvironment } from "./test.js";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
getCoreApi: vi.fn(),
getAuthzApi: vi.fn(),
}));
const SELF_POD = {
namespace: "ns-self",
image: "img:1",
imagePullSecrets: [],
pvcClaimName: "paperclip-pvc",
inheritedEnv: {},
inheritedEnvValueFrom: [],
inheritedEnvFrom: [],
dnsConfig: undefined,
secretVolumes: [],
} as unknown as Awaited<ReturnType<typeof getSelfPodInfo>>;
function makeCtx(config: Record<string, unknown> = {}): AdapterEnvironmentTestContext {
return { adapterType: "opencode_k8s", config } as unknown as AdapterEnvironmentTestContext;
}
function makeAuthz(allowedFor: (resource: string, verb: string) => boolean) {
return {
createSelfSubjectAccessReview: vi.fn().mockImplementation(async ({ body }: { body: { spec: { resourceAttributes: { resource: string; verb: string } } } }) => {
const { resource, verb } = body.spec.resourceAttributes;
return { status: { allowed: allowedFor(resource, verb) } };
}),
};
}
function makeCore(overrides: Partial<{ readNamespace: ReturnType<typeof vi.fn>; readNamespacedSecret: ReturnType<typeof vi.fn>; readNamespacedPersistentVolumeClaim: ReturnType<typeof vi.fn> }> = {}) {
return {
readNamespace: overrides.readNamespace ?? vi.fn().mockResolvedValue({ metadata: { name: "ns" } }),
readNamespacedSecret: overrides.readNamespacedSecret ?? vi.fn().mockResolvedValue({ metadata: { name: "paperclip-secrets" } }),
readNamespacedPersistentVolumeClaim: overrides.readNamespacedPersistentVolumeClaim ?? vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteMany"] } }),
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSelfPodInfo).mockResolvedValue(SELF_POD);
vi.mocked(getCoreApi).mockReturnValue(makeCore() as unknown as ReturnType<typeof getCoreApi>);
vi.mocked(getAuthzApi).mockReturnValue(makeAuthz(() => true) as unknown as ReturnType<typeof getAuthzApi>);
});
describe("testEnvironment — happy path", () => {
it("returns pass when API, namespace, RBAC, secret, and RWX PVC all check out", async () => {
const result = await testEnvironment(makeCtx());
expect(result.adapterType).toBe("opencode_k8s");
expect(result.status).toBe("pass");
expect(result.checks.find((c) => c.code === "k8s_api_reachable")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_pvc_rwx")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_secret_exists")).toBeDefined();
expect(typeof result.testedAt).toBe("string");
});
it("skips namespace lookup and emits k8s_namespace_exists when target == self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(coreApi.readNamespace).not.toHaveBeenCalled();
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")?.message).toContain("pod namespace");
});
it("calls readNamespace when target namespace differs from self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(coreApi.readNamespace).toHaveBeenCalledWith({ name: "ns-other" });
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")).toBeDefined();
});
});
describe("testEnvironment — early-return paths", () => {
it("returns fail and short-circuits when K8s API is unreachable", async () => {
vi.mocked(getSelfPodInfo).mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await testEnvironment(makeCtx());
expect(result.status).toBe("fail");
expect(result.checks.find((c) => c.code === "k8s_api_unreachable")).toBeDefined();
// RBAC, secret, and PVC checks should be skipped when API is unreachable
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(false);
});
});
describe("testEnvironment — namespace warning", () => {
it("emits warn (but proceeds) when readNamespace fails for a different namespace", async () => {
const coreApi = makeCore({
readNamespace: vi.fn().mockRejectedValue(new Error("forbidden")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(result.checks.find((c) => c.code === "k8s_namespace_check_failed")).toBeDefined();
// Should still proceed with downstream checks
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(true);
});
});
describe("testEnvironment — RBAC", () => {
it("emits error checks for denied verbs and degrades status to fail", async () => {
vi.mocked(getAuthzApi).mockReturnValue(
makeAuthz((resource, verb) => !(resource === "jobs" && verb === "create")) as unknown as ReturnType<typeof getAuthzApi>,
);
const result = await testEnvironment(makeCtx());
const denied = result.checks.find((c) => c.code === "k8s_rbac_job_create");
expect(denied?.level).toBe("error");
expect(result.status).toBe("fail");
});
it("emits warn when SelfSubjectAccessReview itself throws", async () => {
vi.mocked(getAuthzApi).mockReturnValue({
createSelfSubjectAccessReview: vi.fn().mockRejectedValue(new Error("SSAR not available")),
} as unknown as ReturnType<typeof getAuthzApi>);
const result = await testEnvironment(makeCtx());
const rbacWarns = result.checks.filter((c) => c.code.startsWith("k8s_rbac_") && c.level === "warn");
expect(rbacWarns.length).toBeGreaterThan(0);
});
});
describe("testEnvironment — secrets", () => {
it("emits warn when the secret is not found", async () => {
const coreApi = makeCore({
readNamespacedSecret: vi.fn().mockRejectedValue(new Error("not found")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_secret_missing")).toBeDefined();
expect(result.status).toBe("warn");
});
it("uses configured secretRef when provided", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
await testEnvironment(makeCtx({ secretRef: "custom-secret" }));
expect(coreApi.readNamespacedSecret).toHaveBeenCalledWith({ name: "custom-secret", namespace: "ns-self" });
});
});
describe("testEnvironment — PVC", () => {
it("emits warn when no PVC is mounted on /paperclip", async () => {
vi.mocked(getSelfPodInfo).mockResolvedValue({ ...SELF_POD, pvcClaimName: null });
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_not_detected")).toBeDefined();
expect(result.status).toBe("warn");
});
it("emits warn when PVC access mode is not ReadWriteMany", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteOnce"] } }),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
const pvcCheck = result.checks.find((c) => c.code === "k8s_pvc_not_rwx");
expect(pvcCheck).toBeDefined();
expect(pvcCheck?.message).toContain("ReadWriteOnce");
expect(result.status).toBe("warn");
});
it("emits warn when reading the PVC fails", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockRejectedValue(new Error("api error")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_check_failed")).toBeDefined();
});
});
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["**/*.test.ts", "src/ui-parser.ts"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist/ui-parser",
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/ui-parser.ts"]
}