340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
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 SecretRef;runner 将按 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";
|
||
}
|