From 46ce5cc59900d49907ed65f6852f0bd83d9efbde Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 25 Apr 2026 12:38:54 +0000 Subject: [PATCH] feat: dedicated PVC per agent for OPENCODE_DB (FAR-63, Option B) Replaces the Option A shared-PVC path implementation with a long-lived dedicated PVC per agent, mounted at /opencode-db with OPENCODE_DB=/opencode-db. Changes: - k8s-client.ts: add getPvc/createPvc/deletePvc CoreV1Api helpers - execute.ts: add ensureAgentDbPvc() that gets-or-creates a PVC named opencode-db- before Job creation; pass agentDbClaimName through to buildJobManifest; return null for ephemeral mode (emptyDir used instead) - job-manifest.ts: accept agentDbClaimName on JobBuildInput; mount dedicated PVC or emptyDir at /opencode-db; set OPENCODE_DB=/opencode-db; revert init container to simple form (no mkdir, no PVC mount) - config-schema.ts: replace opencodeDbMode/opencodeDbPath with agentDbMode (dedicated_pvc|ephemeral, default dedicated_pvc), agentDbStorageClass (required for dedicated_pvc), agentDbStorageCapacity (default 1Gi) - test.ts: add create/delete RBAC checks for persistentvolumeclaims - pvc.test.ts: unit tests for ensureAgentDbPvc (7 cases incl. error paths) - 289/289 tests pass; typecheck clean - No agent-delete hook exists; opencode-db PVC janitor routine is a deferred follow-up task Co-Authored-By: Paperclip --- src/server/config-schema.test.ts | 27 +++++-- src/server/config-schema.ts | 26 ++++--- src/server/execute.test.ts | 2 + src/server/execute.ts | 71 +++++++++++++++++- src/server/job-manifest.test.ts | 125 ++++++++++++------------------- src/server/job-manifest.ts | 72 +++++++++--------- src/server/k8s-client.ts | 43 +++++++++++ src/server/pvc.test.ts | 94 +++++++++++++++++++++++ src/server/test.ts | 2 + 9 files changed, 330 insertions(+), 132 deletions(-) create mode 100644 src/server/pvc.test.ts diff --git a/src/server/config-schema.test.ts b/src/server/config-schema.test.ts index b0015eb..a38cfeb 100644 --- a/src/server/config-schema.test.ts +++ b/src/server/config-schema.test.ts @@ -85,24 +85,39 @@ describe("getConfigSchema", () => { } }); - it("has opencodeDbMode as select with shared_pvc default", () => { + it("has agentDbMode as select with dedicated_pvc default", () => { const schema = getConfigSchema(); - const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbMode"); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "agentDbMode"); expect(field).toBeDefined(); expect(field!.type).toBe("select"); - expect(field!.default).toBe("shared_pvc"); - expect(field!.options).toContainEqual(expect.objectContaining({ value: "shared_pvc" })); + expect(field!.default).toBe("dedicated_pvc"); + expect(field!.options).toContainEqual(expect.objectContaining({ value: "dedicated_pvc" })); expect(field!.options).toContainEqual(expect.objectContaining({ value: "ephemeral" })); }); - it("has opencodeDbPath as optional text field with no default", () => { + it("has agentDbStorageClass as text field with no default", () => { const schema = getConfigSchema(); - const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbPath"); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "agentDbStorageClass"); expect(field).toBeDefined(); expect(field!.type).toBe("text"); expect(field!.default).toBeUndefined(); }); + it("has agentDbStorageCapacity as text field defaulting to 1Gi", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "agentDbStorageCapacity"); + expect(field).toBeDefined(); + expect(field!.type).toBe("text"); + expect(field!.default).toBe("1Gi"); + }); + + it("does not include removed Option A fields", () => { + const schema = getConfigSchema(); + const keys = schema.fields.map((f: ConfigFieldSchema) => f.key); + expect(keys).not.toContain("opencodeDbMode"); + expect(keys).not.toContain("opencodeDbPath"); + }); + it("has nodeSelector and tolerations as textarea", () => { const schema = getConfigSchema(); const nodeField = schema.fields.find((f: ConfigFieldSchema) => f.key === "nodeSelector"); diff --git a/src/server/config-schema.ts b/src/server/config-schema.ts index 5d8af76..7e0dc2e 100644 --- a/src/server/config-schema.ts +++ b/src/server/config-schema.ts @@ -27,22 +27,30 @@ export function getConfigSchema(): AdapterConfigSchema { group: "Core", }, { - key: "opencodeDbMode", - label: "OpenCode DB Mode", + key: "agentDbMode", + label: "Agent DB Mode", type: "select", options: [ - { label: "Shared PVC (per-agent path)", value: "shared_pvc" }, - { label: "Ephemeral (emptyDir)", value: "ephemeral" }, + { label: "Dedicated PVC (per-agent, persistent)", value: "dedicated_pvc" }, + { label: "Ephemeral (emptyDir, lost on Job exit)", value: "ephemeral" }, ], - default: "shared_pvc", - hint: "shared_pvc stores the DB on the PVC at /paperclip/.opencode/db/; ephemeral uses a Job-local emptyDir (lost after each run)", + default: "dedicated_pvc", + hint: "dedicated_pvc creates a long-lived PVC named opencode-db- and mounts it at /opencode-db; ephemeral uses a Job-local emptyDir", group: "Core", }, { - key: "opencodeDbPath", - label: "OpenCode DB Path Override", + key: "agentDbStorageClass", + label: "Agent DB Storage Class", type: "text", - hint: "Optional: override the OPENCODE_DB path (only used in shared_pvc mode); defaults to /paperclip/.opencode/db/", + hint: "Required when agentDbMode is dedicated_pvc — Kubernetes StorageClass name (e.g. standard, gp2, longhorn)", + group: "Core", + }, + { + key: "agentDbStorageCapacity", + label: "Agent DB Storage Capacity", + type: "text", + default: "1Gi", + hint: "PVC size for the agent DB (e.g. 1Gi, 5Gi); only used when agentDbMode is dedicated_pvc", group: "Core", }, diff --git a/src/server/execute.test.ts b/src/server/execute.test.ts index ed22041..38ca21d 100644 --- a/src/server/execute.test.ts +++ b/src/server/execute.test.ts @@ -9,6 +9,8 @@ vi.mock("./k8s-client.js", () => ({ getBatchApi: vi.fn(), getCoreApi: vi.fn(), getLogApi: vi.fn(), + getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-agent-id-test" } }), + createPvc: vi.fn().mockResolvedValue({}), })); vi.mock("./job-manifest.js", () => ({ diff --git a/src/server/execute.ts b/src/server/execute.ts index f16b27c..e044794 100644 --- a/src/server/execute.ts +++ b/src/server/execute.ts @@ -8,7 +8,7 @@ import { isOpenCodeUnknownSessionError, isOpenCodeStepLimitResult, } from "./parse.js"; -import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js"; +import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi, getPvc, createPvc } from "./k8s-client.js"; import { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES } from "./job-manifest.js"; import { LogLineDedupFilter } from "./log-dedup.js"; import type * as k8s from "@kubernetes/client-node"; @@ -831,6 +831,58 @@ function ensureSigtermHandler(): void { }); } +/** + * Ensure the per-agent dedicated PVC exists (dedicated_pvc mode) or return null (ephemeral). + * Returns the PVC claim name on success, null when agentDbMode is "ephemeral". + * Throws when agentDbStorageClass is missing in dedicated_pvc mode. + */ +export async function ensureAgentDbPvc( + agentId: string, + namespace: string, + config: Record, + kubeconfigPath?: string, +): Promise { + const agentDbMode = (asString(config.agentDbMode, "dedicated_pvc").trim() || "dedicated_pvc") as "dedicated_pvc" | "ephemeral"; + if (agentDbMode === "ephemeral") return null; + + // Build a K8s-safe PVC name from the agent ID (UUIDs are already alphanumeric+hyphens) + const agentSlug = agentId.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "").slice(0, 208); + const pvcName = `opencode-db-${agentSlug}`; + + const existing = await getPvc(namespace, pvcName, kubeconfigPath); + if (existing) return pvcName; + + const storageClass = asString(config.agentDbStorageClass, "").trim(); + if (!storageClass) { + throw new Error( + "agentDbStorageClass is required when agentDbMode is \"dedicated_pvc\" but is not configured. " + + "Set agentDbStorageClass in the adapter config or switch agentDbMode to \"ephemeral\".", + ); + } + const capacity = asString(config.agentDbStorageCapacity, "1Gi").trim() || "1Gi"; + + await createPvc(namespace, { + apiVersion: "v1", + kind: "PersistentVolumeClaim", + metadata: { + name: pvcName, + namespace, + labels: { + "app.kubernetes.io/managed-by": "paperclip", + "paperclip.io/agent-id": agentId, + "paperclip.io/pvc-type": "agent-db", + }, + }, + spec: { + accessModes: ["ReadWriteOnce"], + storageClassName: storageClass, + resources: { requests: { storage: capacity } }, + }, + }, kubeconfigPath); + + return pvcName; +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { config: rawConfig, onLog, onMeta } = ctx; const config = parseObject(rawConfig); @@ -985,11 +1037,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }); }); -const mockSelfPodWithPvc: JobBuildInput["selfPod"] = { - ...mockSelfPod, - pvcClaimName: "data-pvc", -}; +describe("agentDbClaimName — OPENCODE_DB env var", () => { + it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db"); + }); -describe("OPENCODE_DB env var", () => { - it("sets OPENCODE_DB to default per-agent path in shared_pvc mode", () => { + it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is null (ephemeral)", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null }); + const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; + expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db"); + }); + + it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => { const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; - const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); - expect(dbEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`); + expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined(); }); - it("uses opencodeDbPath override when set in shared_pvc mode", () => { - const ctx = { ...mockCtx, config: { opencodeDbPath: "/custom/db/path" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); + it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => { + const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } }; + const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" }); const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; - const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); - expect(dbEnv?.value).toBe("/custom/db/path"); - }); - - it("sets OPENCODE_DB to /opencode-db in ephemeral mode", () => { - const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); - const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; - const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); - expect(dbEnv?.value).toBe("/opencode-db"); - }); - - it("ignores opencodeDbPath in ephemeral mode", () => { - const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral", opencodeDbPath: "/ignored" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); - const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; - const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); - expect(dbEnv?.value).toBe("/opencode-db"); - }); - - it("defaults to shared_pvc when opencodeDbMode is unset", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); - const env = result.job.spec?.template?.spec?.containers?.[0].env ?? []; - const dbEnv = env.find((e) => e.name === "OPENCODE_DB"); - expect(dbEnv?.value).toContain("/paperclip/.opencode/db/"); + const dbEntries = env.filter((e) => e.name === "OPENCODE_DB"); + expect(dbEntries).toHaveLength(1); + expect(dbEntries[0].value).toBe("/opencode-db"); }); }); -describe("ephemeral DB volume", () => { - it("adds opencode-db emptyDir volume in ephemeral mode", () => { - const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); +describe("agentDbClaimName — volume wiring", () => { + it("mounts dedicated PVC at /opencode-db when agentDbClaimName is a string", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const volumes = result.job.spec?.template?.spec?.volumes ?? []; - const dbVolume = volumes.find((v) => v.name === "opencode-db"); - expect(dbVolume?.emptyDir).toBeDefined(); - }); + const dbVol = volumes.find((v) => v.name === "opencode-db"); + expect(dbVol?.persistentVolumeClaim?.claimName).toBe("opencode-db-agent-abc"); - it("mounts opencode-db at /opencode-db in the main container in ephemeral mode", () => { - const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPod }); const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? []; - const dbMount = mounts.find((m) => m.name === "opencode-db"); - expect(dbMount?.mountPath).toBe("/opencode-db"); + expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db"); }); - it("does not add opencode-db volume in shared_pvc mode", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + it("mounts emptyDir at /opencode-db when agentDbClaimName is null (ephemeral)", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null }); + const volumes = result.job.spec?.template?.spec?.volumes ?? []; + const dbVol = volumes.find((v) => v.name === "opencode-db"); + expect(dbVol?.emptyDir).toBeDefined(); + expect(dbVol?.persistentVolumeClaim).toBeUndefined(); + + const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? []; + expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db"); + }); + + it("does not add opencode-db volume when agentDbClaimName is undefined", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); const volumes = result.job.spec?.template?.spec?.volumes ?? []; expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined(); }); }); -describe("init container mkdir for shared_pvc", () => { - it("adds mkdir -p to init container command when PVC is present and mode is shared_pvc", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); +describe("init container is unchanged by agentDbClaimName", () => { + it("does not add mkdir or 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; - expect(initCmd?.[2]).toContain("mkdir -p"); - }); - - it("mounts PVC in init container for shared_pvc mode with PVC", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); - const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? []; - expect(initMounts.some((m) => m.name === "data")).toBe(true); - }); - - it("passes OPENCODE_DB_PATH env var to init container in shared_pvc mode with PVC", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc }); + expect(initCmd?.[2]).not.toContain("mkdir"); const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? []; - const dbPathEnv = initEnv.find((e) => e.name === "OPENCODE_DB_PATH"); - expect(dbPathEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`); + expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false); }); - it("does not add mkdir when PVC is not present in shared_pvc mode", () => { - const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); // no PVC - const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; - expect(initCmd?.[2]).not.toContain("mkdir"); - }); - - it("does not add mkdir or PVC mount in ephemeral mode even when PVC is present", () => { - const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } }; - const result = buildJobManifest({ ctx, selfPod: mockSelfPodWithPvc }); - const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command; - expect(initCmd?.[2]).not.toContain("mkdir"); + it("does not add PVC mount to init container for dedicated PVC mode", () => { + const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" }); const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? []; - expect(initMounts.some((m) => m.name === "data")).toBe(false); + expect(initMounts.some((m) => m.name === "opencode-db")).toBe(false); }); }); diff --git a/src/server/job-manifest.ts b/src/server/job-manifest.ts index 104cec8..c74fb6c 100644 --- a/src/server/job-manifest.ts +++ b/src/server/job-manifest.ts @@ -30,6 +30,12 @@ export interface JobBuildInput { * Required when the prompt exceeds LARGE_PROMPT_THRESHOLD_BYTES. */ promptSecretName?: string; + /** + * Claim name of the dedicated agent DB PVC (dedicated_pvc mode) or null for ephemeral emptyDir. + * When provided (string or null), mounts /opencode-db and sets OPENCODE_DB=/opencode-db. + * When undefined, no opencode-db volume is added. + */ + agentDbClaimName?: string | null; } export interface JobBuildResult { @@ -233,13 +239,6 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { const configuredCwd = asString(config.cwd, ""); const workingDir = workspaceCwd || configuredCwd || "/paperclip"; - // OpenCode DB configuration - const opencodeDbMode = (asString(config.opencodeDbMode, "shared_pvc").trim() || "shared_pvc") as "shared_pvc" | "ephemeral"; - const opencodeDbPathOverride = asString(config.opencodeDbPath, "").trim(); - const resolvedDbPath = opencodeDbMode === "ephemeral" - ? "/opencode-db" - : (opencodeDbPathOverride || `/paperclip/.opencode/db/${agent.id}`); - // Job naming: slug + 6-char hash for collision resistance; strip trailing hyphens const agentSlug = sanitizeForK8sName(agent.id); const runSlug = sanitizeForK8sName(runId); @@ -303,12 +302,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { // Build env vars const envVars = buildEnvVars(ctx, selfPod, config); - // OPENCODE_DB: always set after user overrides to enforce per-agent isolation or ephemeral mode - const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB"); - if (dbEnvIdx >= 0) { - envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: resolvedDbPath }; - } else { - envVars.push({ name: "OPENCODE_DB", value: resolvedDbPath }); + // OPENCODE_DB: set when a DB volume is present (dedicated PVC or ephemeral emptyDir) + if (input.agentDbClaimName !== undefined) { + const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB"); + if (dbEnvIdx >= 0) { + envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db" }; + } else { + envVars.push({ name: "OPENCODE_DB", value: "/opencode-db" }); + } } // Runtime config for permissions @@ -360,9 +361,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { volumeMounts.push({ name: "data", mountPath: "/paperclip" }); } - // Ephemeral DB volume (only for ephemeral mode) - if (opencodeDbMode === "ephemeral") { - volumes.push({ name: "opencode-db", emptyDir: {} }); + // OpenCode DB volume: dedicated PVC (string claim name) or ephemeral emptyDir (null) + if (input.agentDbClaimName !== undefined) { + volumes.push( + input.agentDbClaimName !== null + ? { name: "opencode-db", persistentVolumeClaim: { claimName: input.agentDbClaimName } } + : { name: "opencode-db", emptyDir: {} }, + ); volumeMounts.push({ name: "opencode-db", mountPath: "/opencode-db" }); } @@ -395,25 +400,6 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { fsGroupChangePolicy: "OnRootMismatch", }; - // Build init container pieces - // shared_pvc mode: prepend mkdir -p to guarantee the per-agent DB dir exists before opencode starts - const useSharedPvcMkdir = opencodeDbMode === "shared_pvc" && Boolean(selfPod.pvcClaimName); - const dbSetupPrefix = useSharedPvcMkdir ? `mkdir -p "$OPENCODE_DB_PATH" && ` : ""; - const initVolumeMounts: k8s.V1VolumeMount[] = [{ name: "prompt", mountPath: "/tmp/prompt" }]; - const initEnvVars: k8s.V1EnvVar[] = []; - if (input.promptSecretName) { - initVolumeMounts.push({ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true }); - } else { - initEnvVars.push({ name: "PROMPT_CONTENT", value: prompt }); - } - if (useSharedPvcMkdir) { - initVolumeMounts.push({ name: "data", mountPath: "/paperclip" }); - initEnvVars.push({ name: "OPENCODE_DB_PATH", value: resolvedDbPath }); - } - const initContainerCommand = input.promptSecretName - ? `${dbSetupPrefix}cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt` - : `${dbSetupPrefix}printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`; - // Build the main container command // 1. Optionally write opencode runtime config for permission bypass // 2. Pipe prompt into opencode @@ -454,9 +440,19 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult { name: "write-prompt", image: "busybox:1.36", imagePullPolicy: "IfNotPresent", - command: ["sh", "-c", initContainerCommand], - ...(initEnvVars.length > 0 ? { env: initEnvVars } : {}), - volumeMounts: initVolumeMounts, + ...(input.promptSecretName + ? { + command: ["sh", "-c", "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"], + env: [{ name: "PROMPT_CONTENT", value: prompt }], + volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }], + }), securityContext, resources: { requests: { cpu: "10m", memory: "16Mi" }, diff --git a/src/server/k8s-client.ts b/src/server/k8s-client.ts index d862ca9..4937920 100644 --- a/src/server/k8s-client.ts +++ b/src/server/k8s-client.ts @@ -174,3 +174,46 @@ export function resetCache(): void { kcCache.clear(); cachedSelfPod = null; } + +function isNotFound(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const asAny = err as unknown as Record; + if (typeof asAny.statusCode === "number" && asAny.statusCode === 404) return true; + const resp = asAny.response as Record | undefined; + return typeof resp?.statusCode === "number" && resp.statusCode === 404; +} + +/** Returns the PVC if it exists, or null if not found. Throws on other errors. */ +export async function getPvc( + namespace: string, + name: string, + kubeconfigPath?: string, +): Promise { + try { + return await getCoreApi(kubeconfigPath).readNamespacedPersistentVolumeClaim({ name, namespace }); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } +} + +export async function createPvc( + namespace: string, + spec: k8s.V1PersistentVolumeClaim, + kubeconfigPath?: string, +): Promise { + return getCoreApi(kubeconfigPath).createNamespacedPersistentVolumeClaim({ namespace, body: spec }); +} + +/** Deletes a PVC, ignoring 404 (already gone). */ +export async function deletePvc( + namespace: string, + name: string, + kubeconfigPath?: string, +): Promise { + try { + await getCoreApi(kubeconfigPath).deleteNamespacedPersistentVolumeClaim({ name, namespace }); + } catch (err) { + if (!isNotFound(err)) throw err; + } +} diff --git a/src/server/pvc.test.ts b/src/server/pvc.test.ts new file mode 100644 index 0000000..0974f81 --- /dev/null +++ b/src/server/pvc.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ensureAgentDbPvc } from "./execute.js"; +import { getPvc, createPvc } from "./k8s-client.js"; + +vi.mock("./k8s-client.js", () => ({ + getSelfPodInfo: vi.fn(), + getBatchApi: vi.fn(), + getCoreApi: vi.fn(), + getLogApi: vi.fn(), + getPvc: vi.fn(), + createPvc: vi.fn(), +})); + +const AGENT_ID = "623c5ffa-9486-4ddd-8ac4-35747f13069c"; +const NAMESPACE = "paperclip"; +const STORAGE_CLASS = "standard"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("ensureAgentDbPvc", () => { + it("returns null in ephemeral mode without calling K8s", async () => { + const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "ephemeral" }); + expect(result).toBeNull(); + expect(getPvc).not.toHaveBeenCalled(); + expect(createPvc).not.toHaveBeenCalled(); + }); + + it("returns the PVC name when it already exists (dedicated_pvc)", async () => { + vi.mocked(getPvc).mockResolvedValue({ metadata: { name: `opencode-db-${AGENT_ID}` } } as never); + const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { + agentDbMode: "dedicated_pvc", + agentDbStorageClass: STORAGE_CLASS, + }); + expect(result).toBe(`opencode-db-${AGENT_ID}`); + expect(createPvc).not.toHaveBeenCalled(); + }); + + it("creates PVC when it does not exist and returns the name", async () => { + vi.mocked(getPvc).mockResolvedValue(null); + vi.mocked(createPvc).mockResolvedValue({} as never); + const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { + agentDbMode: "dedicated_pvc", + agentDbStorageClass: STORAGE_CLASS, + agentDbStorageCapacity: "2Gi", + }); + expect(result).toBe(`opencode-db-${AGENT_ID}`); + expect(createPvc).toHaveBeenCalledOnce(); + const [ns, spec] = vi.mocked(createPvc).mock.calls[0]; + expect(ns).toBe(NAMESPACE); + expect(spec.spec?.storageClassName).toBe(STORAGE_CLASS); + expect(spec.spec?.resources?.requests?.storage).toBe("2Gi"); + expect(spec.spec?.accessModes).toContain("ReadWriteOnce"); + expect(spec.metadata?.labels?.["paperclip.io/agent-id"]).toBe(AGENT_ID); + }); + + it("defaults to dedicated_pvc mode when agentDbMode is not set", async () => { + vi.mocked(getPvc).mockResolvedValue({ metadata: { name: `opencode-db-${AGENT_ID}` } } as never); + const result = await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbStorageClass: STORAGE_CLASS }); + expect(result).toBe(`opencode-db-${AGENT_ID}`); + }); + + it("defaults storage capacity to 1Gi when agentDbStorageCapacity is not set", async () => { + vi.mocked(getPvc).mockResolvedValue(null); + vi.mocked(createPvc).mockResolvedValue({} as never); + await ensureAgentDbPvc(AGENT_ID, NAMESPACE, { + agentDbMode: "dedicated_pvc", + agentDbStorageClass: STORAGE_CLASS, + }); + const [, spec] = vi.mocked(createPvc).mock.calls[0]; + expect(spec.spec?.resources?.requests?.storage).toBe("1Gi"); + }); + + it("throws when agentDbStorageClass is missing in dedicated_pvc mode", async () => { + vi.mocked(getPvc).mockResolvedValue(null); + await expect( + ensureAgentDbPvc(AGENT_ID, NAMESPACE, { agentDbMode: "dedicated_pvc" }), + ).rejects.toThrow("agentDbStorageClass is required"); + }); + + it("sanitizes agent ID in PVC name (strips non-alphanumeric except hyphens)", async () => { + const weirdId = "Agent/ID:with@special!chars"; + vi.mocked(getPvc).mockResolvedValue(null); + vi.mocked(createPvc).mockResolvedValue({} as never); + const result = await ensureAgentDbPvc(weirdId, NAMESPACE, { + agentDbMode: "dedicated_pvc", + agentDbStorageClass: STORAGE_CLASS, + }); + expect(result).toMatch(/^opencode-db-[a-z0-9-]+$/); + expect(result).not.toContain("/"); + expect(result).not.toContain("@"); + }); +}); diff --git a/src/server/test.ts b/src/server/test.ts index 5341c37..67af73b 100644 --- a/src/server/test.ts +++ b/src/server/test.ts @@ -91,6 +91,8 @@ async function checkRbac( { resource: "secrets", group: "", verb: "delete", code: "k8s_rbac_secret_delete", label: "delete Secrets" }, { resource: "secrets", group: "", verb: "get", code: "k8s_rbac_secret_get", label: "get Secrets" }, { resource: "persistentvolumeclaims", group: "", verb: "get", code: "k8s_rbac_pvc_get", label: "get PersistentVolumeClaims" }, + { resource: "persistentvolumeclaims", group: "", verb: "create", code: "k8s_rbac_pvc_create", label: "create PersistentVolumeClaims" }, + { resource: "persistentvolumeclaims", group: "", verb: "delete", code: "k8s_rbac_pvc_delete", label: "delete PersistentVolumeClaims" }, ]; for (const check of rbacChecks) {