diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts index dbd7214..85004dc 100644 --- a/src/mgr/kubernetes-runner-job.ts +++ b/src/mgr/kubernetes-runner-job.ts @@ -6,7 +6,7 @@ import type { AgentRunStore } from "./store.js"; import type { JsonRecord } from "../common/types.js"; import { stableHash, validateEnvName } from "../common/validation.js"; import { renderRunnerJobManifest } from "../runner/k8s-job.js"; -import type { RunnerTransientEnv } from "../runner/k8s-job.js"; +import type { RunnerSessionPvcOptions, RunnerTransientEnv } from "../runner/k8s-job.js"; const reusableCredentialEnvNames = new Set([ "AUTH_PASSWORD", @@ -75,14 +75,18 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore; } if (isTerminalRunStatus(run.status)) throw new AgentRunError(run.failureKind ?? (run.status === "cancelled" ? "cancelled" : "schema-invalid"), `run ${run.id} is already terminal: ${run.status}`, { httpStatus: 409 }); if (isTerminalCommandState(command.state) || command.state !== "pending") throw new AgentRunError(command.state === "cancelled" ? "cancelled" : "schema-invalid", `command ${commandId} is not pending: ${command.state}`, { httpStatus: 409 }); + let sessionPvc: RunnerSessionPvcOptions | undefined; if (run.sessionRef?.sessionId) { const session = await options.store.getSession(run.sessionRef.sessionId); if (session?.storageKind === "evicted") { throw new AgentRunError("session-store-evicted", `session ${session.sessionId} storage has been evicted; create a new sessionId`, { httpStatus: 409, details: { sessionId: session.sessionId, pvcName: session.storagePvcName ?? null, pvcPhase: session.storagePvcPhase ?? null, valuesPrinted: false } }); } + if (session?.storageKind === "pvc" && session.storagePvcName) { + const subdir = session.codexRolloutSubdir ?? "sessions"; + sessionPvc = { pvcName: session.storagePvcName, namespace: session.storageNamespace ?? "agentrun-v01", mountPath: `/home/agentrun/.codex-${run.backendProfile}/${subdir}`, codexRolloutSubdir: subdir }; + } } - - const renderOptions = { + const renderOptions: Parameters[0] = { run, commandId, managerUrl, @@ -91,6 +95,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore; sourceCommit, transientEnv, ...(serviceAccountName ? { serviceAccountName } : {}), + ...(sessionPvc ? { sessionPvc } : {}), }; const attemptId = optionalString(options.input.attemptId); const runnerId = optionalString(options.input.runnerId); diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index c8f7c27..dbe055b 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -19,9 +19,17 @@ export interface RunnerJobRenderOptions { backoffLimit?: number; ttlSecondsAfterFinished?: number; transientEnv?: RunnerTransientEnv[]; + sessionPvc?: RunnerSessionPvcOptions; dryRun?: boolean; } +export interface RunnerSessionPvcOptions { + pvcName: string; + namespace: string; + mountPath: string; + codexRolloutSubdir: string; +} + export interface RunnerTransientEnv { name: string; value: string; @@ -91,9 +99,10 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani const jobName = `agentrun-v01-runner-${shortDnsHash(options.run.id, attemptId)}`; const secretRefs = credentialProjections(options.run, namespace); const toolCredentials = toolCredentialProjections(options.run, namespace); + const sessionPvc = options.sessionPvc; const warnings: string[] = []; if (secretRefs.length === 0) warnings.push("run executionPolicy.secretScope 未声明 provider SecretRef;runner 将按 secret-unavailable 上报,而不会降级直连外部凭据"); - const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs, toolCredentials }); + const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs, toolCredentials, sessionPvc }); const manifest: JsonRecord = { apiVersion: "batch/v1", kind: "Job", @@ -132,6 +141,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani volumeMounts: [ { name: "runner-home", mountPath: "/home/agentrun" }, ...secretRefs.map((item) => ({ name: item.volumeName, mountPath: item.projectionMountPath, readOnly: true })), + ...(sessionPvc ? [{ name: "agentrun-sessions", mountPath: sessionPvc.mountPath, readOnly: false }] : []), ], resources: { requests: { cpu: "250m", memory: "512Mi" }, @@ -144,7 +154,11 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani }, }, ], - volumes: [{ name: "runner-home", emptyDir: {} }, ...secretRefs.map(secretVolume)], + volumes: [ + { name: "runner-home", emptyDir: {} }, + ...secretRefs.map(secretVolume), + ...(sessionPvc ? [{ name: "agentrun-sessions", persistentVolumeClaim: { claimName: sessionPvc.pvcName } }] : []), + ], }, }, }, @@ -152,7 +166,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani return { manifest, namespace, jobName, runnerId, attemptId, sourceCommit, serviceAccountName, secretRefs, toolCredentials, warnings, ttlSecondsAfterFinished }; } -function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[] }): JsonRecord[] { +function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; sessionPvc: RunnerSessionPvcOptions | undefined }): JsonRecord[] { const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile); const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile); return [ @@ -180,6 +194,12 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "HOME", value: "/home/agentrun" }, { name: "CODEX_HOME", value: codexHome }, ...(selectedSecret ? [{ name: "AGENTRUN_CODEX_SECRET_HOME", value: selectedSecret.projectionMountPath }] : []), + ...(context.sessionPvc ? [ + { name: "AGENTRUN_SESSION_PVC_NAME", value: context.sessionPvc.pvcName }, + { name: "AGENTRUN_SESSION_PVC_NAMESPACE", value: context.sessionPvc.namespace }, + { name: "AGENTRUN_SESSION_PVC_MOUNT_PATH", value: context.sessionPvc.mountPath }, + { name: "AGENTRUN_CODEX_ROLLOUT_SUBDIR", value: context.sessionPvc.codexRolloutSubdir }, + ] : []), ...toolCredentialEnvVars(context.toolCredentials), ...transientEnvVars(options.transientEnv ?? []), ]; diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 27f4eec..bd8faaf 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -153,7 +153,49 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin } finally { await new Promise((resolve) => serverWithKubectl.server.close(() => resolve())); } - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied"] }; + const sessionRunRecord: RunRecord = { + id: "run-selftest-session-pvc", + tenantId: "unidesk", + projectId: "pikasTech/unidesk", + workspaceRef: { kind: "host-path", path: context.workspace }, + sessionRef: { sessionId: "sess-selftest-runner-001" }, + resourceBundleRef: null, + providerId: "G14", + backendProfile: "codex", + executionPolicy: { sandbox: "workspace-write", approval: "never", timeoutMs: 15_000, network: "default", secretScope: { allowCredentialEcho: false, providerCredentials: [{ profile: "codex", secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"] } }] } }, + traceSink: null, + status: "pending", + terminalStatus: null, + failureKind: null, + failureMessage: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + claimedBy: null, + leaseExpiresAt: null, + }; + const sessionPvcRendered = renderRunnerJobDryRun({ + run: sessionRunRecord, + commandId: "cmd-selftest-session-pvc", + managerUrl: server.baseUrl, + image: "127.0.0.1:5000/agentrun/agentrun-mgr-env:self-test", + sessionPvc: { pvcName: "agentrun-v01-session-sess-selftest-runner-001", namespace: "agentrun-v01", mountPath: "/home/agentrun/.codex-codex/sessions", codexRolloutSubdir: "sessions" }, + }); + const sessionPvcManifest = sessionPvcRendered.manifest as JsonRecord; + const sessionPvcSpec = sessionPvcManifest.spec as JsonRecord; + const sessionPvcTemplate = sessionPvcSpec.template as JsonRecord; + const sessionPvcPodSpec = sessionPvcTemplate.spec as JsonRecord; + const sessionPvcContainers = Array.isArray(sessionPvcPodSpec.containers) ? sessionPvcPodSpec.containers as JsonRecord[] : []; + const sessionPvcMounts = Array.isArray(sessionPvcContainers[0]?.volumeMounts) ? sessionPvcContainers[0].volumeMounts as JsonRecord[] : []; + const sessionPvcVols = Array.isArray(sessionPvcPodSpec.volumes) ? sessionPvcPodSpec.volumes as JsonRecord[] : []; + assert.ok(sessionPvcMounts.some((m) => m.name === "agentrun-sessions" && m.mountPath === "/home/agentrun/.codex-codex/sessions" && m.readOnly === false), "session pvc volume mount must be present"); + assert.ok(sessionPvcVols.some((v) => typeof v === "object" && v !== null && (v as JsonRecord).persistentVolumeClaim !== undefined && ((v as JsonRecord).persistentVolumeClaim as JsonRecord).claimName === "agentrun-v01-session-sess-selftest-runner-001"), "session pvc volume must reference the per-session PVC"); + const sessionPvcEnv = Array.isArray(sessionPvcContainers[0]?.env) ? sessionPvcContainers[0].env as JsonRecord[] : []; + const envMap = new Map(sessionPvcEnv.map((e) => [String(e.name), String(e.value)])); + assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAME"), "agentrun-v01-session-sess-selftest-runner-001"); + assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01"); + assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions"); + assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions"); + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }