feat: implement OPENCODE_DB per-agent path on shared PVC (FAR-62)
- shared_pvc mode (default): sets OPENCODE_DB to /paperclip/.opencode/db/<agentId> and prepends mkdir -p to the busybox init container when a PVC is present - ephemeral mode: mounts an emptyDir at /opencode-db and points OPENCODE_DB there - config-schema: adds opencodeDbMode (select, default shared_pvc) and opencodeDbPath (optional text override for shared_pvc path) - No agent-delete hook exists in this adapter; per-agent DB dir cleanup is deferred to a janitor routine (follow-up work) - 284/284 tests pass; typecheck clean Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -85,6 +85,24 @@ describe("getConfigSchema", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("has opencodeDbMode as select with shared_pvc default", () => {
|
||||||
|
const schema = getConfigSchema();
|
||||||
|
const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbMode");
|
||||||
|
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!.options).toContainEqual(expect.objectContaining({ value: "ephemeral" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has opencodeDbPath as optional text field with no default", () => {
|
||||||
|
const schema = getConfigSchema();
|
||||||
|
const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "opencodeDbPath");
|
||||||
|
expect(field).toBeDefined();
|
||||||
|
expect(field!.type).toBe("text");
|
||||||
|
expect(field!.default).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
@@ -26,6 +26,25 @@ export function getConfigSchema(): AdapterConfigSchema {
|
|||||||
hint: "Inject runtime config with permission.external_directory=allow",
|
hint: "Inject runtime config with permission.external_directory=allow",
|
||||||
group: "Core",
|
group: "Core",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "opencodeDbMode",
|
||||||
|
label: "OpenCode DB Mode",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Shared PVC (per-agent path)", value: "shared_pvc" },
|
||||||
|
{ label: "Ephemeral (emptyDir)", value: "ephemeral" },
|
||||||
|
],
|
||||||
|
default: "shared_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)",
|
||||||
|
group: "Core",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "opencodeDbPath",
|
||||||
|
label: "OpenCode DB Path Override",
|
||||||
|
type: "text",
|
||||||
|
hint: "Optional: override the OPENCODE_DB path (only used in shared_pvc mode); defaults to /paperclip/.opencode/db/<agentId>",
|
||||||
|
group: "Core",
|
||||||
|
},
|
||||||
|
|
||||||
// Kubernetes fields
|
// Kubernetes fields
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -286,6 +286,111 @@ describe("buildJobManifest", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockSelfPodWithPvc: JobBuildInput["selfPod"] = {
|
||||||
|
...mockSelfPod,
|
||||||
|
pvcClaimName: "data-pvc",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OPENCODE_DB env var", () => {
|
||||||
|
it("sets OPENCODE_DB to default per-agent path in shared_pvc mode", () => {
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses opencodeDbPath override when set in shared_pvc mode", () => {
|
||||||
|
const ctx = { ...mockCtx, config: { opencodeDbPath: "/custom/db/path" } };
|
||||||
|
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("/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/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
||||||
|
const dbVolume = volumes.find((v) => v.name === "opencode-db");
|
||||||
|
expect(dbVolume?.emptyDir).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add opencode-db volume in shared_pvc mode", () => {
|
||||||
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPodWithPvc });
|
||||||
|
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 });
|
||||||
|
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 });
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? [];
|
||||||
|
expect(initMounts.some((m) => m.name === "data")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sanitizeLabelValue", () => {
|
describe("sanitizeLabelValue", () => {
|
||||||
it("passes through clean values unchanged", () => {
|
it("passes through clean values unchanged", () => {
|
||||||
expect(sanitizeLabelValue("abc-123")).toBe("abc-123");
|
expect(sanitizeLabelValue("abc-123")).toBe("abc-123");
|
||||||
|
|||||||
+43
-13
@@ -233,6 +233,13 @@ 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);
|
||||||
@@ -296,6 +303,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
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime config for permissions
|
// Runtime config for permissions
|
||||||
const runtimeConfigJson = buildRuntimeConfigJson(config);
|
const runtimeConfigJson = buildRuntimeConfigJson(config);
|
||||||
|
|
||||||
@@ -345,6 +360,12 @@ 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)
|
||||||
|
if (opencodeDbMode === "ephemeral") {
|
||||||
|
volumes.push({ name: "opencode-db", emptyDir: {} });
|
||||||
|
volumeMounts.push({ name: "opencode-db", mountPath: "/opencode-db" });
|
||||||
|
}
|
||||||
|
|
||||||
// Mount secret volumes inherited from the Deployment pod
|
// Mount secret volumes inherited from the Deployment pod
|
||||||
for (const sv of selfPod.secretVolumes) {
|
for (const sv of selfPod.secretVolumes) {
|
||||||
volumes.push({
|
volumes.push({
|
||||||
@@ -374,6 +395,25 @@ 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
|
||||||
@@ -414,19 +454,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
|
|||||||
name: "write-prompt",
|
name: "write-prompt",
|
||||||
image: "busybox:1.36",
|
image: "busybox:1.36",
|
||||||
imagePullPolicy: "IfNotPresent",
|
imagePullPolicy: "IfNotPresent",
|
||||||
...(input.promptSecretName
|
command: ["sh", "-c", initContainerCommand],
|
||||||
? {
|
...(initEnvVars.length > 0 ? { env: initEnvVars } : {}),
|
||||||
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt"],
|
volumeMounts: initVolumeMounts,
|
||||||
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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user