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-<agentId> 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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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 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).toBeDefined();
|
||||||
expect(field!.type).toBe("select");
|
expect(field!.type).toBe("select");
|
||||||
expect(field!.default).toBe("shared_pvc");
|
expect(field!.default).toBe("dedicated_pvc");
|
||||||
expect(field!.options).toContainEqual(expect.objectContaining({ value: "shared_pvc" }));
|
expect(field!.options).toContainEqual(expect.objectContaining({ value: "dedicated_pvc" }));
|
||||||
expect(field!.options).toContainEqual(expect.objectContaining({ value: "ephemeral" }));
|
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 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).toBeDefined();
|
||||||
expect(field!.type).toBe("text");
|
expect(field!.type).toBe("text");
|
||||||
expect(field!.default).toBeUndefined();
|
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", () => {
|
it("has nodeSelector and tolerations as textarea", () => {
|
||||||
const schema = getConfigSchema();
|
const schema = getConfigSchema();
|
||||||
const nodeField = schema.fields.find((f: ConfigFieldSchema) => f.key === "nodeSelector");
|
const nodeField = schema.fields.find((f: ConfigFieldSchema) => f.key === "nodeSelector");
|
||||||
|
|||||||
@@ -27,22 +27,30 @@ export function getConfigSchema(): AdapterConfigSchema {
|
|||||||
group: "Core",
|
group: "Core",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "opencodeDbMode",
|
key: "agentDbMode",
|
||||||
label: "OpenCode DB Mode",
|
label: "Agent DB Mode",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Shared PVC (per-agent path)", value: "shared_pvc" },
|
{ label: "Dedicated PVC (per-agent, persistent)", value: "dedicated_pvc" },
|
||||||
{ label: "Ephemeral (emptyDir)", value: "ephemeral" },
|
{ label: "Ephemeral (emptyDir, lost on Job exit)", value: "ephemeral" },
|
||||||
],
|
],
|
||||||
default: "shared_pvc",
|
default: "dedicated_pvc",
|
||||||
hint: "shared_pvc stores the DB on the PVC at /paperclip/.opencode/db/<agentId>; ephemeral uses a Job-local emptyDir (lost after each run)",
|
hint: "dedicated_pvc creates a long-lived PVC named opencode-db-<agentId> and mounts it at /opencode-db; ephemeral uses a Job-local emptyDir",
|
||||||
group: "Core",
|
group: "Core",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "opencodeDbPath",
|
key: "agentDbStorageClass",
|
||||||
label: "OpenCode DB Path Override",
|
label: "Agent DB Storage Class",
|
||||||
type: "text",
|
type: "text",
|
||||||
hint: "Optional: override the OPENCODE_DB path (only used in shared_pvc mode); defaults to /paperclip/.opencode/db/<agentId>",
|
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",
|
group: "Core",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ vi.mock("./k8s-client.js", () => ({
|
|||||||
getBatchApi: vi.fn(),
|
getBatchApi: vi.fn(),
|
||||||
getCoreApi: vi.fn(),
|
getCoreApi: vi.fn(),
|
||||||
getLogApi: 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", () => ({
|
vi.mock("./job-manifest.js", () => ({
|
||||||
|
|||||||
+70
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
isOpenCodeUnknownSessionError,
|
isOpenCodeUnknownSessionError,
|
||||||
isOpenCodeStepLimitResult,
|
isOpenCodeStepLimitResult,
|
||||||
} from "./parse.js";
|
} 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 { buildJobManifest, LARGE_PROMPT_THRESHOLD_BYTES } from "./job-manifest.js";
|
||||||
import { LogLineDedupFilter } from "./log-dedup.js";
|
import { LogLineDedupFilter } from "./log-dedup.js";
|
||||||
import type * as k8s from "@kubernetes/client-node";
|
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<string, unknown>,
|
||||||
|
kubeconfigPath?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { config: rawConfig, onLog, onMeta } = ctx;
|
const { config: rawConfig, onLog, onMeta } = ctx;
|
||||||
const config = parseObject(rawConfig);
|
const config = parseObject(rawConfig);
|
||||||
@@ -985,11 +1037,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
// non-fatal: skill bundle is optional
|
// non-fatal: skill bundle is optional
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure per-agent DB PVC exists (or get null for ephemeral mode)
|
||||||
|
let agentDbClaimName: string | null | undefined;
|
||||||
|
try {
|
||||||
|
agentDbClaimName = await ensureAgentDbPvc(agentId, guardNamespace, config, kubeconfigPath);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
await onLog("stderr", `[paperclip] Failed to ensure agent DB PVC: ${msg}\n`);
|
||||||
|
return {
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: msg,
|
||||||
|
errorCode: "k8s_job_create_failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const buildArgs = {
|
const buildArgs = {
|
||||||
ctx,
|
ctx,
|
||||||
selfPod,
|
selfPod,
|
||||||
instructionsContent: instructionsContent || undefined,
|
instructionsContent: instructionsContent || undefined,
|
||||||
skillsBundleContent: skillsBundleContent || undefined,
|
skillsBundleContent: skillsBundleContent || undefined,
|
||||||
|
agentDbClaimName,
|
||||||
};
|
};
|
||||||
const firstBuild = buildJobManifest(buildArgs);
|
const firstBuild = buildJobManifest(buildArgs);
|
||||||
const { jobName, namespace, prompt, opencodeArgs, promptMetrics } = firstBuild;
|
const { jobName, namespace, prompt, opencodeArgs, promptMetrics } = firstBuild;
|
||||||
|
|||||||
@@ -286,108 +286,77 @@ describe("buildJobManifest", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockSelfPodWithPvc: JobBuildInput["selfPod"] = {
|
describe("agentDbClaimName — OPENCODE_DB env var", () => {
|
||||||
...mockSelfPod,
|
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => {
|
||||||
pvcClaimName: "data-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 /opencode-db when agentDbClaimName is null (ephemeral)", () => {
|
||||||
it("sets OPENCODE_DB to default per-agent path in shared_pvc mode", () => {
|
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 result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
||||||
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
||||||
const dbEnv = env.find((e) => e.name === "OPENCODE_DB");
|
expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined();
|
||||||
expect(dbEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses opencodeDbPath override when set in shared_pvc mode", () => {
|
it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => {
|
||||||
const ctx = { ...mockCtx, config: { opencodeDbPath: "/custom/db/path" } };
|
const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } };
|
||||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||||
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
||||||
const dbEnv = env.find((e) => e.name === "OPENCODE_DB");
|
const dbEntries = env.filter((e) => e.name === "OPENCODE_DB");
|
||||||
expect(dbEnv?.value).toBe("/custom/db/path");
|
expect(dbEntries).toHaveLength(1);
|
||||||
});
|
expect(dbEntries[0].value).toBe("/opencode-db");
|
||||||
|
|
||||||
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/");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ephemeral DB volume", () => {
|
describe("agentDbClaimName — volume wiring", () => {
|
||||||
it("adds opencode-db emptyDir volume in ephemeral mode", () => {
|
it("mounts dedicated PVC at /opencode-db when agentDbClaimName is a string", () => {
|
||||||
const ctx = { ...mockCtx, config: { opencodeDbMode: "ephemeral" } };
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||||
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
||||||
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
||||||
const dbVolume = volumes.find((v) => v.name === "opencode-db");
|
const dbVol = volumes.find((v) => v.name === "opencode-db");
|
||||||
expect(dbVolume?.emptyDir).toBeDefined();
|
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 mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? [];
|
||||||
const dbMount = mounts.find((m) => m.name === "opencode-db");
|
expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db");
|
||||||
expect(dbMount?.mountPath).toBe("/opencode-db");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add opencode-db volume in shared_pvc mode", () => {
|
it("mounts emptyDir at /opencode-db when agentDbClaimName is null (ephemeral)", () => {
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc });
|
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 ?? [];
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
||||||
expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined();
|
expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("init container mkdir for shared_pvc", () => {
|
describe("init container is unchanged by agentDbClaimName", () => {
|
||||||
it("adds mkdir -p to init container command when PVC is present and mode is shared_pvc", () => {
|
it("does not add mkdir or extra env vars to init container for dedicated PVC mode", () => {
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc });
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
||||||
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
|
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
|
||||||
expect(initCmd?.[2]).toContain("mkdir -p");
|
expect(initCmd?.[2]).not.toContain("mkdir");
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
|
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
|
||||||
const dbPathEnv = initEnv.find((e) => e.name === "OPENCODE_DB_PATH");
|
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
|
||||||
expect(dbPathEnv?.value).toBe(`/paperclip/.opencode/db/${mockCtx.agent.id}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add mkdir when PVC is not present in shared_pvc mode", () => {
|
it("does not add PVC mount to init container for dedicated PVC mode", () => {
|
||||||
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod }); // no PVC
|
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]).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");
|
|
||||||
const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? [];
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+34
-38
@@ -30,6 +30,12 @@ export interface JobBuildInput {
|
|||||||
* Required when the prompt exceeds LARGE_PROMPT_THRESHOLD_BYTES.
|
* Required when the prompt exceeds LARGE_PROMPT_THRESHOLD_BYTES.
|
||||||
*/
|
*/
|
||||||
promptSecretName?: string;
|
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 {
|
export interface JobBuildResult {
|
||||||
@@ -233,13 +239,6 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
const configuredCwd = asString(config.cwd, "");
|
const configuredCwd = asString(config.cwd, "");
|
||||||
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
|
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
|
// Job naming: slug + 6-char hash for collision resistance; strip trailing hyphens
|
||||||
const agentSlug = sanitizeForK8sName(agent.id);
|
const agentSlug = sanitizeForK8sName(agent.id);
|
||||||
const runSlug = sanitizeForK8sName(runId);
|
const runSlug = sanitizeForK8sName(runId);
|
||||||
@@ -303,12 +302,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
// Build env vars
|
// Build env vars
|
||||||
const envVars = buildEnvVars(ctx, selfPod, config);
|
const envVars = buildEnvVars(ctx, selfPod, config);
|
||||||
|
|
||||||
// OPENCODE_DB: always set after user overrides to enforce per-agent isolation or ephemeral mode
|
// OPENCODE_DB: set when a DB volume is present (dedicated PVC or ephemeral emptyDir)
|
||||||
const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB");
|
if (input.agentDbClaimName !== undefined) {
|
||||||
if (dbEnvIdx >= 0) {
|
const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB");
|
||||||
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: resolvedDbPath };
|
if (dbEnvIdx >= 0) {
|
||||||
} else {
|
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db" };
|
||||||
envVars.push({ name: "OPENCODE_DB", value: resolvedDbPath });
|
} else {
|
||||||
|
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime config for permissions
|
// Runtime config for permissions
|
||||||
@@ -360,9 +361,13 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
volumeMounts.push({ name: "data", mountPath: "/paperclip" });
|
volumeMounts.push({ name: "data", mountPath: "/paperclip" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ephemeral DB volume (only for ephemeral mode)
|
// OpenCode DB volume: dedicated PVC (string claim name) or ephemeral emptyDir (null)
|
||||||
if (opencodeDbMode === "ephemeral") {
|
if (input.agentDbClaimName !== undefined) {
|
||||||
volumes.push({ name: "opencode-db", emptyDir: {} });
|
volumes.push(
|
||||||
|
input.agentDbClaimName !== null
|
||||||
|
? { name: "opencode-db", persistentVolumeClaim: { claimName: input.agentDbClaimName } }
|
||||||
|
: { name: "opencode-db", emptyDir: {} },
|
||||||
|
);
|
||||||
volumeMounts.push({ name: "opencode-db", mountPath: "/opencode-db" });
|
volumeMounts.push({ name: "opencode-db", mountPath: "/opencode-db" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,25 +400,6 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
fsGroupChangePolicy: "OnRootMismatch",
|
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
|
// Build the main container command
|
||||||
// 1. Optionally write opencode runtime config for permission bypass
|
// 1. Optionally write opencode runtime config for permission bypass
|
||||||
// 2. Pipe prompt into opencode
|
// 2. Pipe prompt into opencode
|
||||||
@@ -454,9 +440,19 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
name: "write-prompt",
|
name: "write-prompt",
|
||||||
image: "busybox:1.36",
|
image: "busybox:1.36",
|
||||||
imagePullPolicy: "IfNotPresent",
|
imagePullPolicy: "IfNotPresent",
|
||||||
command: ["sh", "-c", initContainerCommand],
|
...(input.promptSecretName
|
||||||
...(initEnvVars.length > 0 ? { env: initEnvVars } : {}),
|
? {
|
||||||
volumeMounts: initVolumeMounts,
|
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,
|
securityContext,
|
||||||
resources: {
|
resources: {
|
||||||
requests: { cpu: "10m", memory: "16Mi" },
|
requests: { cpu: "10m", memory: "16Mi" },
|
||||||
|
|||||||
@@ -174,3 +174,46 @@ export function resetCache(): void {
|
|||||||
kcCache.clear();
|
kcCache.clear();
|
||||||
cachedSelfPod = null;
|
cachedSelfPod = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNotFound(err: unknown): boolean {
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
const asAny = err as unknown as Record<string, unknown>;
|
||||||
|
if (typeof asAny.statusCode === "number" && asAny.statusCode === 404) return true;
|
||||||
|
const resp = asAny.response as Record<string, unknown> | 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<k8s.V1PersistentVolumeClaim | null> {
|
||||||
|
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<k8s.V1PersistentVolumeClaim> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await getCoreApi(kubeconfigPath).deleteNamespacedPersistentVolumeClaim({ name, namespace });
|
||||||
|
} catch (err) {
|
||||||
|
if (!isNotFound(err)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("@");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,8 @@ async function checkRbac(
|
|||||||
{ resource: "secrets", group: "", verb: "delete", code: "k8s_rbac_secret_delete", label: "delete Secrets" },
|
{ 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: "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: "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) {
|
for (const check of rbacChecks) {
|
||||||
|
|||||||
Reference in New Issue
Block a user