feat: add aipod spec Artificer assembly
This commit is contained in:
@@ -273,7 +273,7 @@ function unideskSshEndpointEnvFromRecord(record: JsonRecord, fieldName: string):
|
||||
return { name, value: rawValue, sensitive: true };
|
||||
}
|
||||
|
||||
function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; envName: string; secretKey: string }>, namespace: string): JsonRecord {
|
||||
function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; kind?: string; envName?: string; secretKey?: string; mountPath?: string }>, namespace: string): JsonRecord {
|
||||
return {
|
||||
count: items.length,
|
||||
items: items.map((item) => ({
|
||||
@@ -282,7 +282,7 @@ function summarizeToolCredentials(items: Array<{ tool: string; purpose: string |
|
||||
name: item.secretRef.name,
|
||||
namespace: item.secretRef.namespace ?? namespace,
|
||||
keys: item.secretRef.keys ?? [],
|
||||
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
|
||||
projection: item.kind === "volume" ? { kind: "volume", mountPath: item.mountPath ?? null } : { kind: "env", envName: item.envName ?? null, secretKey: item.secretKey ?? null },
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
|
||||
+29
-2
@@ -15,6 +15,8 @@ import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessio
|
||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||
import type { SessionPvcOptions } from "./session-pvc.js";
|
||||
import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||
import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js";
|
||||
import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js";
|
||||
|
||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||
@@ -46,6 +48,8 @@ export interface ManagerServerOptions {
|
||||
};
|
||||
sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string };
|
||||
providerProfileOptions?: { namespace?: string; kubectlCommand?: string };
|
||||
toolCredentialOptions?: { namespace?: string; kubectlCommand?: string };
|
||||
aipodSpecDir?: string;
|
||||
}
|
||||
|
||||
export interface StartedManagerServer {
|
||||
@@ -60,12 +64,14 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr
|
||||
const runnerJobDefaults = options.runnerJobDefaults;
|
||||
const sessionPvcDefaults = options.sessionPvcOptions;
|
||||
const providerProfileDefaults = options.providerProfileOptions;
|
||||
const toolCredentialDefaults = options.toolCredentialOptions;
|
||||
const aipodSpecDir = options.aipodSpecDir ?? process.env.AGENTRUN_AIPOD_SPEC_DIR;
|
||||
const server = createServer(async (req, res) => {
|
||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}) });
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}), ...(toolCredentialDefaults ? { toolCredentialDefaults } : {}), ...(aipodSpecDir ? { aipodSpecDir } : {}) });
|
||||
writeJson(res, 200, { ok: true, data, traceId });
|
||||
} catch (error) {
|
||||
const agentError = normalizeError(error);
|
||||
@@ -200,7 +206,7 @@ function compactRecoveryActions(value: JsonValue | undefined): JsonValue[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]> }): Promise<JsonValue> {
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults, toolCredentialDefaults, aipodSpecDir }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]>; toolCredentialDefaults?: NonNullable<ManagerServerOptions["toolCredentialOptions"]>; aipodSpecDir?: string }): Promise<JsonValue> {
|
||||
const path = url.pathname;
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
const database = await store.health();
|
||||
@@ -208,6 +214,19 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } };
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) as JsonValue;
|
||||
const toolCredentialMatch = path.match(/^\/api\/v1\/tool-credentials\/([^/]+)$/u);
|
||||
if (method === "GET" && toolCredentialMatch) return await showToolCredential(decodeURIComponent(toolCredentialMatch[1] ?? ""), toolCredentialDefaults) as JsonValue;
|
||||
const toolCredentialGithubSshMatch = path.match(/^\/api\/v1\/tool-credentials\/github-ssh\/credential$/u);
|
||||
if (method === "PUT" && toolCredentialGithubSshMatch) return await setGithubSshToolCredential(body ?? {}, toolCredentialDefaults) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/aipod-specs") return await listAipodSpecs(aipodSpecDir) as JsonValue;
|
||||
if (method === "POST" && path === "/api/v1/aipod-specs") return await applyAipodSpec(body ?? {}, aipodSpecDir) as JsonValue;
|
||||
const aipodSpecMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)$/u);
|
||||
if (method === "GET" && aipodSpecMatch) return await showAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue;
|
||||
if (method === "PUT" && aipodSpecMatch) return await applyNamedAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), body, aipodSpecDir) as JsonValue;
|
||||
if (method === "DELETE" && aipodSpecMatch) return await deleteAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue;
|
||||
const aipodSpecRenderMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)\/render$/u);
|
||||
if (method === "POST" && aipodSpecRenderMatch) return await renderAipodSpecByName(decodeURIComponent(aipodSpecRenderMatch[1] ?? ""), asRecord(body ?? {}, "aipodSpecRender"), aipodSpecDir) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
|
||||
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
|
||||
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||
@@ -536,6 +555,14 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
throw new AgentRunError("schema-invalid", `unsupported route ${method} ${path}`, { httpStatus: 404 });
|
||||
}
|
||||
|
||||
async function applyNamedAipodSpec(name: string, body: unknown, dir?: string): Promise<JsonValue> {
|
||||
const spec = aipodSpecFromInput(body ?? {}, `api:${name}`);
|
||||
if (spec.metadata.name.toLowerCase() !== name.trim().toLowerCase()) {
|
||||
throw new AgentRunError("schema-invalid", "aipod-spec URL name must match metadata.name", { httpStatus: 400, details: { urlName: name, metadataName: spec.metadata.name, valuesPrinted: false } });
|
||||
}
|
||||
return await applyAipodSpec(spec, dir) as JsonValue;
|
||||
}
|
||||
|
||||
function integerQuery(url: URL, key: string, fallback: number): number {
|
||||
const value = Number(url.searchParams.get(key));
|
||||
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import type { JsonRecord } from "../common/types.js";
|
||||
import { asRecord } from "../common/validation.js";
|
||||
import { redactJson, redactText } from "../common/redaction.js";
|
||||
|
||||
const defaultNamespace = "agentrun-v01";
|
||||
const annotationPrefix = "agentrun.pikastech.local/tool-credential";
|
||||
|
||||
interface ToolCredentialSpec {
|
||||
name: string;
|
||||
tool: string;
|
||||
purpose: string;
|
||||
secretName: string;
|
||||
keys: readonly string[];
|
||||
mutable: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCredentialOptions {
|
||||
namespace?: string;
|
||||
kubectlCommand?: string;
|
||||
}
|
||||
|
||||
const toolCredentialSpecs: readonly ToolCredentialSpec[] = Object.freeze([
|
||||
{ name: "github-ssh", tool: "github", purpose: "github-ssh", secretName: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], mutable: true },
|
||||
{ name: "unidesk-ssh", tool: "unidesk-ssh", purpose: "ssh-passthrough", secretName: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"], mutable: false },
|
||||
]);
|
||||
|
||||
export async function listToolCredentials(options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
const items = await Promise.all(toolCredentialSpecs.map((spec) => toolCredentialStatus(spec, options)));
|
||||
return { action: "tool-credential-list", items, count: items.length, valuesPrinted: false };
|
||||
}
|
||||
|
||||
export async function showToolCredential(name: string, options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
return toolCredentialStatus(requiredSpec(name), options);
|
||||
}
|
||||
|
||||
export async function setGithubSshToolCredential(body: unknown, options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
const spec = requiredSpec("github-ssh");
|
||||
const record = asRecord(body ?? {}, "githubSshToolCredential");
|
||||
const privateKey = credentialTextField(record, "privateKey", 131_072);
|
||||
const knownHosts = credentialTextField(record, "knownHosts", 131_072);
|
||||
const config = optionalTextField(record, "config", 32_768) ?? defaultGithubSshConfig();
|
||||
validatePrivateKey(privateKey);
|
||||
validateKnownHosts(knownHosts);
|
||||
validateSshConfig(config);
|
||||
const namespace = runtimeNamespace(options);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const secretData = {
|
||||
id_ed25519: base64Data(privateKey),
|
||||
known_hosts: base64Data(knownHosts),
|
||||
config: base64Data(config),
|
||||
} satisfies JsonRecord;
|
||||
const secretManifest: JsonRecord = {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: spec.secretName,
|
||||
namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/tool": spec.tool,
|
||||
"agentrun.pikastech.local/purpose": spec.purpose,
|
||||
},
|
||||
annotations: {
|
||||
[`${annotationPrefix}-name`]: spec.name,
|
||||
[`${annotationPrefix}-tool`]: spec.tool,
|
||||
[`${annotationPrefix}-purpose`]: spec.purpose,
|
||||
[`${annotationPrefix}-private-key-hash-suffix`]: shortHash(privateKey),
|
||||
[`${annotationPrefix}-known-hosts-hash-suffix`]: shortHash(knownHosts),
|
||||
[`${annotationPrefix}-config-hash-suffix`]: shortHash(config),
|
||||
[`${annotationPrefix}-updated-at`]: updatedAt,
|
||||
},
|
||||
},
|
||||
type: "Opaque",
|
||||
data: secretData,
|
||||
};
|
||||
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
||||
return {
|
||||
action: "tool-credential-github-ssh-updated",
|
||||
mutation: true,
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
configured: true,
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: stringPath(applied, ["metadata", "resourceVersion"]),
|
||||
privateKeyHashSuffix: shortHash(privateKey),
|
||||
knownHostsHashSuffix: shortHash(knownHosts),
|
||||
configHashSuffix: shortHash(config),
|
||||
updatedAt: stringPath(applied, ["metadata", "annotations", `${annotationPrefix}-updated-at`]) ?? updatedAt,
|
||||
credentialValuesPrinted: false,
|
||||
valuesPrinted: false,
|
||||
pollCommands: {
|
||||
show: "./scripts/agentrun tool-credentials show github-ssh",
|
||||
list: "./scripts/agentrun tool-credentials list",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function toolCredentialStatus(spec: ToolCredentialSpec, options: ToolCredentialOptions): Promise<JsonRecord> {
|
||||
const namespace = runtimeNamespace(options);
|
||||
const secret = await kubectlGetSecret(spec.secretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||
if (!secret) {
|
||||
return {
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
mutable: spec.mutable,
|
||||
configured: false,
|
||||
failureKind: "secret-unavailable",
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: null,
|
||||
updatedAt: null,
|
||||
keyPresence: Object.fromEntries(spec.keys.map((key) => [key, false])),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const data = asOptionalRecord(secret.data);
|
||||
const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations);
|
||||
return {
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
mutable: spec.mutable,
|
||||
configured: hasRequiredKeys(data, spec.keys),
|
||||
failureKind: hasRequiredKeys(data, spec.keys) ? null : "secret-unavailable",
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
|
||||
updatedAt: stringPath(annotations, [`${annotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]),
|
||||
keyPresence: Object.fromEntries(spec.keys.map((key) => [key, typeof data?.[key] === "string" && String(data[key]).length > 0])),
|
||||
keyHashSuffixes: Object.fromEntries(spec.keys.map((key) => [key, hashDataKey(data, key)])),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSpec(name: string): ToolCredentialSpec {
|
||||
const spec = toolCredentialSpecs.find((item) => item.name === name);
|
||||
if (!spec) throw new AgentRunError("schema-invalid", `tool credential ${name} is not supported in v0.1`, { httpStatus: 404, details: { supported: toolCredentialSpecs.map((item) => item.name), valuesPrinted: false } });
|
||||
return spec;
|
||||
}
|
||||
|
||||
function secretRefSummary(spec: ToolCredentialSpec, namespace: string): JsonRecord {
|
||||
return { namespace, name: spec.secretName, keys: [...spec.keys], valuesPrinted: false };
|
||||
}
|
||||
|
||||
function runtimeNamespace(options: ToolCredentialOptions): string {
|
||||
return options.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? defaultNamespace;
|
||||
}
|
||||
|
||||
function credentialTextField(record: JsonRecord, key: string, maxBytes: number): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
|
||||
const text = normalizeText(value);
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } });
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalTextField(record: JsonRecord, key: string, maxBytes: number): string | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new AgentRunError("schema-invalid", `${key} must be a string`, { httpStatus: 400 });
|
||||
if (value.trim().length === 0) return undefined;
|
||||
const text = normalizeText(value);
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } });
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\r\n?/gu, "\n").endsWith("\n") ? value.replace(/\r\n?/gu, "\n") : `${value.replace(/\r\n?/gu, "\n")}\n`;
|
||||
}
|
||||
|
||||
function validatePrivateKey(value: string): void {
|
||||
if (!/^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\n/mu.test(value) || !/\n-----END [A-Z0-9 ]*PRIVATE KEY-----\n?$/mu.test(value)) {
|
||||
throw new AgentRunError("schema-invalid", "privateKey must be an OpenSSH or PEM private key", { httpStatus: 400, details: { valuesPrinted: false } });
|
||||
}
|
||||
}
|
||||
|
||||
function validateKnownHosts(value: string): void {
|
||||
const lines = value.split(/\n/u).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
if (lines.length === 0) throw new AgentRunError("schema-invalid", "knownHosts must contain at least one host key", { httpStatus: 400 });
|
||||
}
|
||||
|
||||
function validateSshConfig(value: string): void {
|
||||
if (!/Host\s+/iu.test(value) || !/IdentityFile\s+/iu.test(value)) {
|
||||
throw new AgentRunError("schema-invalid", "config must contain Host and IdentityFile entries", { httpStatus: 400, details: { valuesPrinted: false } });
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGithubSshConfig(): string {
|
||||
return [
|
||||
"Host github.com",
|
||||
" HostName ssh.github.com",
|
||||
" User git",
|
||||
" Port 443",
|
||||
" IdentityFile ~/.ssh/id_ed25519",
|
||||
" IdentitiesOnly yes",
|
||||
" StrictHostKeyChecking yes",
|
||||
" UserKnownHostsFile ~/.ssh/known_hosts",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise<JsonRecord | null> {
|
||||
const result = await runKubectl(kubectlCommand, ["get", "secret", name, "-n", namespace, "-o", "json"]);
|
||||
if (result.code !== 0) {
|
||||
const failureText = `${result.stderr}\n${result.stdout}`;
|
||||
if (/notfound|not found|not-found/iu.test(failureText)) return null;
|
||||
throw new AgentRunError("infra-failed", `kubectl get tool credential Secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
||||
}
|
||||
return parseKubectlObject(result.stdout, "tool credential secret");
|
||||
}
|
||||
|
||||
async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
||||
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
||||
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
||||
const stdin = `${JSON.stringify(manifest)}\n`;
|
||||
const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], stdin);
|
||||
if (replace.code === 0) return parseKubectlObject(replace.stdout, "tool credential secret replace", { redactSecretData: true });
|
||||
if (isKubectlNotFoundFailure(replace)) {
|
||||
const created = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], stdin);
|
||||
if (created.code === 0) return parseKubectlObject(created.stdout, "tool credential secret create", { redactSecretData: true });
|
||||
throw new AgentRunError("infra-failed", `kubectl create tool credential Secret ${namespace}/${name} failed with code ${created.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(created.stderr.slice(-2000)), stdout: redactText(created.stdout.slice(-1000)) }) });
|
||||
}
|
||||
throw new AgentRunError("infra-failed", `kubectl replace tool credential Secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) });
|
||||
}
|
||||
|
||||
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
||||
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
|
||||
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
|
||||
const child = spawn(kubectlCommand, args, { stdio: ["pipe", "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); });
|
||||
child.stdin.end(stdin ?? "");
|
||||
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, stderr };
|
||||
}
|
||||
|
||||
function parseKubectlObject(stdout: string, label: string, options: { redactSecretData?: boolean } = {}): JsonRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return options.redactSecretData ? redactSecretObject(parsed as JsonRecord) : parsed as JsonRecord;
|
||||
} catch (error) {
|
||||
throw new AgentRunError("infra-failed", `kubectl returned invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: label.includes("secret") ? "REDACTED" : redactText(stdout.slice(0, 1000)) } });
|
||||
}
|
||||
throw new AgentRunError("infra-failed", `kubectl returned non-object JSON for ${label}`, { httpStatus: 502 });
|
||||
}
|
||||
|
||||
function redactSecretObject(object: JsonRecord): JsonRecord {
|
||||
const copy = JSON.parse(JSON.stringify(object)) as JsonRecord;
|
||||
if (copy.data) copy.data = "REDACTED";
|
||||
if (copy.stringData) copy.stringData = "REDACTED";
|
||||
return copy;
|
||||
}
|
||||
|
||||
function hasRequiredKeys(data: JsonRecord | null, keys: readonly string[]): boolean {
|
||||
return keys.every((key) => typeof data?.[key] === "string" && String(data[key]).length > 0);
|
||||
}
|
||||
|
||||
function hashDataKey(data: JsonRecord | null, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
try {
|
||||
return shortHash(Buffer.from(value, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return shortHash(value);
|
||||
}
|
||||
}
|
||||
|
||||
function base64Data(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function objectPath(record: JsonRecord, path: string[]): unknown {
|
||||
let current: unknown = record;
|
||||
for (const key of path) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function stringPath(record: JsonRecord | null | undefined, path: string[]): string | null {
|
||||
if (!record) return null;
|
||||
const value = objectPath(record, path);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asOptionalRecord(value: unknown): JsonRecord | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
|
||||
}
|
||||
Reference in New Issue
Block a user