Files
pikasTech-agentrun/src/runner/k8s-job.ts
T
2026-06-08 23:31:33 +08:00

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { stableHash } from "../common/validation.js";
import type { BackendProfile, ExecutionPolicy, JsonRecord, JsonValue, RunRecord, SecretRef } from "../common/types.js";
import { backendProfileSpec } from "../common/backend-profiles.js";
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
const defaultResourceBinPath = "/usr/local/bin";
export interface RunnerJobRenderOptions {
run: RunRecord;
commandId: string;
managerUrl: string;
image: string;
namespace?: string;
attemptId?: string;
runnerId?: string;
sourceCommit?: string;
serviceAccountName?: string;
imagePullPolicy?: string;
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;
sensitive?: boolean;
}
interface CredentialProjection {
profile: BackendProfile | string;
secretRef: SecretRef;
volumeName: string;
runtimeMountPath: string;
projectionMountPath: string;
}
interface ToolCredentialProjection {
tool: string;
purpose: string | null;
secretRef: SecretRef;
envName: string;
secretKey: string;
}
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
const render = renderRunnerJobManifest({ ...options, dryRun: true });
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
return {
dryRun: true,
mutation: false,
action: "render-kubernetes-job",
jobIdentity: {
kind: "Job",
namespace: render.namespace,
name: render.jobName,
serviceAccountName: render.serviceAccountName,
},
runner: {
runId: options.run.id,
commandId: options.commandId,
attemptId: render.attemptId,
runnerId: render.runnerId,
backendProfile: options.run.backendProfile,
managerUrl: options.managerUrl,
sourceCommit: render.sourceCommit,
},
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(options.transientEnv ?? []),
retention: {
ttlSecondsAfterFinished: render.ttlSecondsAfterFinished,
},
pollCommands: {
run: `./scripts/agentrun runs show ${options.run.id} --manager-url ${options.managerUrl}`,
events: `./scripts/agentrun runs events ${options.run.id} --manager-url ${options.managerUrl} --after-seq 0 --limit 100`,
},
warnings: render.warnings,
manifest,
};
}
export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { manifest: JsonRecord; namespace: string; jobName: string; runnerId: string; attemptId: string; sourceCommit: string; serviceAccountName: string; secretRefs: CredentialProjection[]; toolCredentials: ToolCredentialProjection[]; warnings: string[]; ttlSecondsAfterFinished: number } {
const namespace = options.namespace ?? "agentrun-v01";
const attemptId = options.attemptId ?? `attempt_${Date.now().toString(36)}`;
const runnerId = options.runnerId ?? `runner_${shortHash(`${options.run.id}:${attemptId}:${options.commandId}`)}`;
const sourceCommit = options.sourceCommit ?? process.env.AGENTRUN_SOURCE_COMMIT ?? "unknown";
const serviceAccountName = options.serviceAccountName ?? "agentrun-v01-runner";
const ttlSecondsAfterFinished = options.ttlSecondsAfterFinished ?? 86_400;
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 SecretRefrunner 将按 secret-unavailable 上报,而不会降级直连外部凭据");
const env = runnerEnv(options, { namespace, jobName, runnerId, attemptId, sourceCommit, secretRefs, toolCredentials, sessionPvc });
const manifest: JsonRecord = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: jobName,
namespace,
labels: labels(options.run, jobName),
annotations: {
"agentrun.pikastech.local/run-id": options.run.id,
"agentrun.pikastech.local/command-id": options.commandId,
"agentrun.pikastech.local/dry-run-render": String(options.dryRun === true),
},
},
spec: {
backoffLimit: options.backoffLimit ?? 0,
ttlSecondsAfterFinished,
template: {
metadata: {
labels: labels(options.run, jobName),
annotations: {
"agentrun.pikastech.local/run-id": options.run.id,
"agentrun.pikastech.local/command-id": options.commandId,
},
},
spec: {
serviceAccountName,
automountServiceAccountToken: false,
restartPolicy: "Never",
containers: [
{
name: "runner",
image: options.image,
imagePullPolicy: options.imagePullPolicy ?? "IfNotPresent",
command: ["/opt/agentrun/deploy/runtime/boot/agentrun-runner.sh"],
env,
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" },
limits: { cpu: "2", memory: "4Gi" },
},
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: false,
capabilities: { drop: ["ALL"] },
},
},
],
volumes: [
{ name: "runner-home", emptyDir: {} },
...secretRefs.map(secretVolume),
...(sessionPvc ? [{ name: "agentrun-sessions", persistentVolumeClaim: { claimName: sessionPvc.pvcName } }] : []),
],
},
},
},
};
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[]; sessionPvc: RunnerSessionPvcOptions | undefined }): JsonRecord[] {
const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile);
const codexHome = selectedSecret?.runtimeMountPath ?? defaultRuntimeHome(options.run.backendProfile);
return [
{ name: "AGENTRUN_MGR_URL", value: options.managerUrl },
{ name: "AGENTRUN_RUN_ID", value: options.run.id },
{ name: "AGENTRUN_COMMAND_ID", value: options.commandId },
{ name: "AGENTRUN_ATTEMPT_ID", value: context.attemptId },
{ name: "AGENTRUN_RUNNER_ID", value: context.runnerId },
{ name: "AGENTRUN_BACKEND_PROFILE", value: options.run.backendProfile },
{ name: "AGENTRUN_EXECUTION_POLICY_JSON", value: JSON.stringify(options.run.executionPolicy) },
{ name: "AGENTRUN_SESSION_REF_JSON", value: JSON.stringify(options.run.sessionRef ?? null) },
{ name: "AGENTRUN_RESOURCE_BUNDLE_JSON", value: JSON.stringify(options.run.resourceBundleRef ?? null) },
{ name: "AGENTRUN_WORKSPACE_ROOT", value: "/home/agentrun/workspaces" },
{ name: "AGENTRUN_RESOURCE_BIN_PATH", value: defaultResourceBinPath },
{ name: "AGENTRUN_SOURCE_COMMIT", value: context.sourceCommit },
{ name: "AGENTRUN_BOOT_COMMIT", value: context.sourceCommit },
{ name: "AGENTRUN_BOOT_REPO_URL", value: defaultBootRepoUrl },
{ name: "AGENTRUN_BOOT_MODE", value: "runner" },
{ name: "AGENTRUN_APP_ROOT", value: "/home/agentrun/agentrun-source" },
{ name: "AGENTRUN_RUNTIME_NAMESPACE", value: context.namespace },
{ name: "AGENTRUN_K8S_JOB_NAME", value: context.jobName },
{ name: "AGENTRUN_LOG_PATH", value: "/tmp/agentrun-runner.jsonl" },
{ name: "AGENTRUN_RUNNER_IDLE_TIMEOUT_MS", value: "600000" },
{ name: "AGENTRUN_RUNNER_POLL_INTERVAL_MS", value: "250" },
{ 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 ?? []),
];
}
function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] {
return items.map((item) => ({
name: item.envName,
valueFrom: {
secretKeyRef: {
name: item.secretRef.name,
key: item.secretKey,
},
},
}));
}
function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] {
return items.map((item) => ({ name: item.name, value: item.value }));
}
function summarizeTransientEnv(items: RunnerTransientEnv[]): JsonRecord {
return {
count: items.length,
names: items.map((item) => item.name),
valuesPrinted: false,
};
}
function summarizeToolCredentials(items: ToolCredentialProjection[], namespace: string): JsonRecord {
return {
count: items.length,
items: items.map((item) => ({
tool: item.tool,
purpose: item.purpose,
name: item.secretRef.name,
namespace: item.secretRef.namespace ?? namespace,
keys: item.secretRef.keys ?? [],
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
valuesPrinted: false,
})),
valuesPrinted: false,
};
}
function redactTransientEnvInManifest(manifest: JsonRecord, items: RunnerTransientEnv[]): JsonRecord {
if (items.length === 0) return manifest;
const names = new Set(items.map((item) => item.name));
const copy = JSON.parse(JSON.stringify(manifest)) as JsonRecord;
const spec = copy.spec as JsonRecord | undefined;
const template = spec?.template as JsonRecord | undefined;
const podSpec = template?.spec as JsonRecord | undefined;
const containers = Array.isArray(podSpec?.containers) ? podSpec.containers as JsonRecord[] : [];
for (const container of containers) {
const env = Array.isArray(container.env) ? container.env as JsonRecord[] : [];
for (const entry of env) {
if (typeof entry.name === "string" && names.has(entry.name)) entry.value = "REDACTED";
}
}
return copy;
}
function credentialProjections(run: RunRecord, namespace: string): CredentialProjection[] {
const policy: ExecutionPolicy = run.executionPolicy;
const credentials = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === run.backendProfile);
return credentials.map((item, index) => ({
profile: item.profile,
secretRef: credentialSecretRef(item.profile, item.secretRef, namespace),
volumeName: sanitizeVolumeName(`${String(item.profile)}-${index}`),
runtimeMountPath: normalizeMountPath(item.secretRef.mountPath, String(item.profile)),
projectionMountPath: `/var/run/agentrun/secrets/${sanitizeVolumeName(`${String(item.profile)}-${index}`)}`,
}));
}
function credentialSecretRef(profile: string, secretRef: SecretRef, namespace: string): SecretRef {
const spec = backendProfileSpec(profile);
const keys = [...new Set([...(secretRef.keys ?? []), ...(spec?.requiredSecretKeys ?? [])])];
return { ...secretRef, namespace: secretRef.namespace ?? namespace, ...(keys.length > 0 ? { keys } : {}) };
}
function toolCredentialProjections(run: RunRecord, namespace: string): ToolCredentialProjection[] {
const policy: ExecutionPolicy = run.executionPolicy;
const credentials = policy.secretScope.toolCredentials ?? [];
return credentials.map((item) => ({
tool: item.tool,
purpose: item.purpose ?? null,
secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace },
envName: item.projection.envName,
secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName,
}));
}
function secretVolume(item: CredentialProjection): JsonRecord {
const secret: JsonRecord = {
secretName: item.secretRef.name,
defaultMode: 256,
};
const keys = item.secretRef.keys ?? [];
if (keys.length > 0) secret.items = keys.map((key) => ({ key, path: key, mode: 256 }));
return { name: item.volumeName, secret };
}
function normalizeMountPath(value: string | undefined, profile: string): string {
const spec = backendProfileSpec(profile);
const suffix = spec ? spec.profile : sanitizeVolumeName(profile);
if (!value || value === "~/.codex") return defaultRuntimeHome(suffix);
if (value.startsWith("~/")) return `/home/agentrun/${value.slice(2)}-${suffix}`;
return value;
}
function defaultRuntimeHome(profile: string): string {
return `/home/agentrun/.codex-${sanitizeVolumeName(profile)}`;
}
function labels(run: RunRecord, jobName: string): JsonRecord {
return {
"app.kubernetes.io/name": "agentrun-runner",
"app.kubernetes.io/component": "runner",
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/lane": "v0.1",
"agentrun.pikastech.local/run-hash": shortHash(run.id),
"job-name": jobName,
};
}
function shortDnsHash(...parts: string[]): string {
return shortHash(parts.join(":"));
}
function shortHash(value: JsonValue): string {
return stableHash(value).slice(0, 12);
}
function sanitizeVolumeName(value: string): string {
const sanitized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
return sanitized.length > 0 ? sanitized.slice(0, 40) : "credential";
}