fix: runner proxy and transient env secret

This commit is contained in:
Codex
2026-06-09 22:49:53 +08:00
parent 3daaa32427
commit e8b0b7896a
4 changed files with 297 additions and 32 deletions
+112 -7
View File
@@ -69,6 +69,10 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
const serviceAccountName = optionalString(options.input.serviceAccountName) ?? options.defaults.serviceAccountName;
const idempotencyKey = optionalString(options.input.idempotencyKey);
const transientEnv = assembleToolContextTransientEnv(run.executionPolicy, transientEnvField(options.input.transientEnv), options.defaults);
const attemptId = optionalString(options.input.attemptId) ?? `attempt_${Date.now().toString(36)}`;
const runnerId = optionalString(options.input.runnerId);
const transientEnvSecretName = transientEnv.length > 0 ? transientEnvSecretNameForRun(run.id, commandId, attemptId) : null;
const renderTransientEnv = transientEnvSecretName ? transientEnvWithSecretRefs(transientEnv, transientEnvSecretName) : transientEnv;
const normalizedPayload = {
commandId,
image,
@@ -105,14 +109,32 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
image,
namespace,
sourceCommit,
transientEnv,
transientEnv: renderTransientEnv,
...(serviceAccountName ? { serviceAccountName } : {}),
...(sessionPvc ? { sessionPvc } : {}),
};
const attemptId = optionalString(options.input.attemptId);
const runnerId = optionalString(options.input.runnerId);
const render = renderRunnerJobManifest({ ...renderOptions, ...(attemptId ? { attemptId } : {}), ...(runnerId ? { runnerId } : {}) });
const created = await kubectlCreate(render.manifest, options.defaults.kubectlCommand ?? "kubectl");
const render = renderRunnerJobManifest({ ...renderOptions, attemptId, ...(runnerId ? { runnerId } : {}) });
const kubectlCommand = options.defaults.kubectlCommand ?? "kubectl";
let transientEnvSecretCreated = false;
let transientEnvSecretOwnerAttached = false;
let created: JsonRecord | null = null;
try {
if (transientEnvSecretName) {
await kubectlCreate(transientEnvSecretManifest({ namespace: render.namespace, name: transientEnvSecretName, runId: run.id, commandId, attemptId: render.attemptId, runnerId: render.runnerId, jobName: render.jobName, items: transientEnv }), kubectlCommand, "runner transient env secret");
transientEnvSecretCreated = true;
}
created = await kubectlCreate(render.manifest, kubectlCommand, "runner job");
} catch (error) {
if (transientEnvSecretName && transientEnvSecretCreated) await kubectlDeleteSecret(transientEnvSecretName, render.namespace, kubectlCommand);
throw error;
}
if (!created) throw new AgentRunError("infra-failed", "kubectl did not return created runner job metadata", { httpStatus: 502 });
if (transientEnvSecretName) {
const owner = await kubectlPatchSecretOwnerReference(transientEnvSecretName, render.namespace, { name: render.jobName, uid: objectPath(created, ["metadata", "uid"]) }, kubectlCommand);
transientEnvSecretOwnerAttached = owner.ok;
if (!owner.ok) render.warnings.push("transientEnv Secret ownerReference patch failed; Kubernetes TTL may not garbage-collect this per-job Secret automatically");
}
const transientEnvSecretResponse = transientEnvSecretName ? summarizeTransientEnvSecret(transientEnvSecretName, render.namespace, transientEnv, render.jobName, transientEnvSecretOwnerAttached) : null;
const response = {
action: "create-kubernetes-job",
mutation: true,
@@ -143,6 +165,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })),
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
transientEnv: summarizeTransientEnv(transientEnv),
transientEnvSecret: transientEnvSecretResponse,
retention: {
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
},
@@ -184,6 +207,7 @@ export async function createKubernetesRunnerJob(options: { store: AgentRunStore;
jobName: saved.jobName,
idempotencyKey: idempotencyKey ? "present" : null,
transientEnv: summarizeTransientEnv(transientEnv),
transientEnvSecret: transientEnvSecretResponse,
toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace),
sessionRef: summarizeSessionRef(run.sessionRef ?? null),
resourceBundleRef: summarizeResourceBundleRef(run.resourceBundleRef ?? null),
@@ -273,7 +297,56 @@ function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord {
};
}
async function kubectlCreate(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
function transientEnvWithSecretRefs(items: RunnerTransientEnv[], secretName: string): RunnerTransientEnv[] {
return items.map((item) => ({ ...item, secretRef: { name: secretName, key: item.name } }));
}
function transientEnvSecretNameForRun(runId: string, commandId: string, attemptId: string): string {
return `agentrun-v01-runner-env-${stableHash({ runId, commandId, attemptId }).slice(0, 20)}`;
}
function transientEnvSecretManifest(options: { namespace: string; name: string; runId: string; commandId: string; attemptId: string; runnerId: string; jobName: string; items: RunnerTransientEnv[] }): JsonRecord {
const stringData: JsonRecord = {};
for (const item of options.items) stringData[item.name] = item.value;
return {
apiVersion: "v1",
kind: "Secret",
metadata: {
name: options.name,
namespace: options.namespace,
labels: {
"app.kubernetes.io/name": "agentrun-runner",
"app.kubernetes.io/component": "runner-transient-env",
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/lane": "v0.1",
"agentrun.pikastech.local/run-hash": stableHash(options.runId).slice(0, 12),
"agentrun.pikastech.local/runner-job": options.jobName,
},
annotations: {
"agentrun.pikastech.local/run-id": options.runId,
"agentrun.pikastech.local/command-id": options.commandId,
"agentrun.pikastech.local/attempt-id": options.attemptId,
"agentrun.pikastech.local/runner-id": options.runnerId,
"agentrun.pikastech.local/runner-job": options.jobName,
"agentrun.pikastech.local/values-printed": "false",
},
},
type: "Opaque",
stringData,
};
}
function summarizeTransientEnvSecret(name: string, namespace: string, items: RunnerTransientEnv[], jobName: string, ownerReferenceAttached: boolean): JsonRecord {
return {
name,
namespace,
keys: items.map((item) => item.name),
valuesPrinted: false,
ownerReference: { kind: "Job", name: jobName, attached: ownerReferenceAttached },
};
}
async function kubectlCreate(manifest: JsonRecord, kubectlCommand: string, label = "runner job"): Promise<JsonRecord> {
const child = spawn(kubectlCommand, ["create", "-f", "-", "-o", "json"], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
@@ -289,7 +362,7 @@ async function kubectlCreate(manifest: JsonRecord, kubectlCommand: string): Prom
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
});
if (result.code !== 0) {
throw new AgentRunError("infra-failed", `kubectl create runner job failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(stderr.slice(-4000)), stdout: redactText(stdout.slice(-2000)), signal: result.signal }) });
throw new AgentRunError("infra-failed", `kubectl create ${label} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(stderr.slice(-4000)), stdout: redactText(stdout.slice(-2000)), signal: result.signal }) });
}
try {
const parsed = JSON.parse(stdout) as unknown;
@@ -300,6 +373,38 @@ async function kubectlCreate(manifest: JsonRecord, kubectlCommand: string): Prom
throw new AgentRunError("infra-failed", "kubectl returned non-object JSON", { httpStatus: 502 });
}
async function kubectlDeleteSecret(name: string, namespace: string, kubectlCommand: string): Promise<void> {
await kubectlRun(kubectlCommand, ["delete", "secret", name, "-n", namespace, "--ignore-not-found=true"]);
}
async function kubectlPatchSecretOwnerReference(name: string, namespace: string, owner: { name: string; uid: string | null }, kubectlCommand: string): Promise<{ ok: boolean }> {
if (!owner.uid) return { ok: false };
const patch = {
metadata: {
ownerReferences: [{ apiVersion: "batch/v1", kind: "Job", name: owner.name, uid: owner.uid, controller: false, blockOwnerDeletion: false }],
},
};
const result = await kubectlRun(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "-p", JSON.stringify(patch), "-o", "json"]);
return { ok: result.code === 0 };
}
async function kubectlRun(kubectlCommand: string, args: string[]): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
const child = spawn(kubectlCommand, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
}).catch((error: unknown) => {
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
});
return { ...result, stdout: redactText(stdout), stderr: redactText(stderr) };
}
function stringField(record: JsonRecord, key: string): string {
const value = record[key];
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });