From 90fae0c1c46c95a920f0da0a91cabd9b37bc0992 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 04:32:07 +0000 Subject: [PATCH] feat: sync AgentRun lane database secret --- scripts/src/agentrun.ts | 170 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index d3bc5909..ddad76e7 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -1,4 +1,6 @@ +import { createHash } from "node:crypto"; import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { rootPath, type UniDeskConfig } from "./config"; import type { RenderedCliResult } from "./output"; @@ -65,6 +67,8 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun explain task", "bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", "bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", + "bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run", + "bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm", "bun scripts/cli.ts agentrun control-plane status", "bun scripts/cli.ts agentrun control-plane status --full", "bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", @@ -103,6 +107,7 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str if (group === "control-plane") { if (action === "plan") return await controlPlanePlan(config, parseStatusOptions(actionArgs)); if (action === "status") return await status(config, parseStatusOptions(actionArgs)); + if (action === "secret-sync") return await secretSync(config, parseSecretSyncOptions(actionArgs)); if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs)); if (action === "trigger-current") return await triggerCurrent(config, parseTriggerOptions(actionArgs)); if (action === "refresh") return await refresh(config, parseConfirmOptions(actionArgs)); @@ -227,10 +232,11 @@ function agentRunHelpText(args: string[]): string { return [ "Usage: bun scripts/cli.ts agentrun control-plane [options]", "", - "Actions: plan, status, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", + "Actions: plan, status, secret-sync, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", "Examples:", " bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", " bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", + " bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run", " bun scripts/cli.ts agentrun control-plane status", " bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", " bun scripts/cli.ts agentrun control-plane expose --dry-run", @@ -1635,6 +1641,11 @@ interface ConfirmOptions { dryRun: boolean; } +interface SecretSyncOptions extends ConfirmOptions { + node: string | null; + lane: string | null; +} + interface GitMirrorOptions extends ConfirmOptions { timeoutSeconds: number; wait: boolean; @@ -1751,6 +1762,32 @@ function parseTriggerOptions(args: string[]): TriggerOptions { return parseConfirmOptions(args); } +function parseSecretSyncOptions(args: string[]): SecretSyncOptions { + const base = parseConfirmOptions(args); + let node: string | null = null; + let lane: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm" || arg === "--dry-run") continue; + if (arg === "--node") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); + node = value; + index += 1; + continue; + } + if (arg === "--lane") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); + lane = value; + index += 1; + continue; + } + throw new Error(`unsupported secret-sync option: ${arg}`); + } + return { ...base, node, lane }; +} + function parseConfirmOptions(args: string[]): ConfirmOptions { if (args.includes("--confirm") && args.includes("--dry-run")) throw new Error("accepts only one of --confirm or --dry-run"); return { @@ -2101,6 +2138,65 @@ async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, tar }; } +async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise> { + const { configPath, spec } = resolveAgentRunLaneTarget(options); + if (spec.database.secretSourceRef === null) { + return { + ok: false, + command: "agentrun control-plane secret-sync", + mode: "yaml-declared-node-lane", + configPath, + target: agentRunLaneSummary(spec), + degradedReason: "database-secret-source-not-declared", + valuesPrinted: false, + }; + } + const source = readSecretSourceValue(spec, spec.database.secretSourceRef, spec.database.secretRef.key); + const plan = { + namespace: spec.runtime.namespace, + secret: spec.database.secretRef.name, + key: spec.database.secretRef.key, + sourceRef: spec.database.secretSourceRef, + sourcePath: source.redactedPath, + fingerprint: source.fingerprint, + valueBytes: source.valueBytes, + valuesPrinted: false, + }; + if (options.dryRun || !options.confirm) { + return { + ok: true, + command: "agentrun control-plane secret-sync", + mode: "dry-run", + mutation: false, + configPath, + target: agentRunLaneSummary(spec), + plan, + next: { + confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`, + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + }, + valuesPrinted: false, + }; + } + const result = await capture(config, spec.nodeKubeRoute, ["script", "--", secretSyncScript(spec, source.value)]); + const payload = captureJsonPayload(result); + return { + ok: result.exitCode === 0 && payload.ok !== false, + command: "agentrun control-plane secret-sync", + mode: "confirmed-sync", + mutation: true, + configPath, + target: agentRunLaneSummary(spec), + plan, + result: payload, + capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), + next: { + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + }, + valuesPrinted: false, + }; +} + async function exposeAgentRun(_config: UniDeskConfig, options: ConfirmOptions): Promise> { const clientConfig = readAgentRunClientConfig(); const exposure = clientConfig.publicExposure; @@ -2731,6 +2827,78 @@ function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string { ].join("\n"); } +function readSecretSourceValue(spec: AgentRunLaneSpec, sourceRef: string, key: string): { redactedPath: string; value: string; valueBytes: number; fingerprint: string } { + if (sourceRef.startsWith("/") || sourceRef.includes("..")) throw new Error(`secret sourceRef must be relative without ..: ${sourceRef}`); + const secretRoot = resolveSecretSourceRoot(spec); + const sourcePath = join(secretRoot, ...sourceRef.split("/")); + if (!existsSync(sourcePath)) throw new Error(`secret source ${sourceRef} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); + const values = parseEnvFile(readFileSync(sourcePath, "utf8")); + const value = values.get(key); + if (value === undefined || value.length === 0) throw new Error(`secret source ${sourceRef} is missing required key ${key}`); + return { + redactedPath: `.state/secrets/${sourceRef}`, + value, + valueBytes: Buffer.byteLength(value, "utf8"), + fingerprint: `sha256:${createHash("sha256").update(value).digest("hex")}`, + }; +} + +function resolveSecretSourceRoot(spec: AgentRunLaneSpec): string { + if (spec.database.configRef === null) return rootPath(".state", "secrets"); + const configPath = spec.database.configRef.startsWith("/") ? spec.database.configRef : rootPath(spec.database.configRef); + const configRoot = record(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown); + const secrets = record(configRoot.secrets); + const root = stringOrNull(secrets.root); + if (root === null || !root.startsWith("/") || root.includes("..")) throw new Error(`${spec.database.configRef}.secrets.root must be an absolute path without ..`); + return root; +} + +function parseEnvFile(text: string): Map { + const result = new Map(); + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const index = line.indexOf("="); + if (index <= 0) continue; + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + result.set(key, value); + } + return result; +} + +function secretSyncScript(spec: AgentRunLaneSpec, value: string): string { + const encoded = Buffer.from(value, "utf8").toString("base64"); + return [ + "set -eu", + `namespace=${shQuote(spec.runtime.namespace)}`, + `secret_name=${shQuote(spec.database.secretRef.name)}`, + `secret_key=${shQuote(spec.database.secretRef.key)}`, + `secret_value_b64=${shQuote(encoded)}`, + "tmp_dir=$(mktemp -d)", + "trap 'rm -rf \"$tmp_dir\"' EXIT", + "secret_file=\"$tmp_dir/secret-value\"", + "printf '%s' \"$secret_value_b64\" | base64 -d > \"$secret_file\"", + "kubectl create namespace \"$namespace\" --dry-run=client -o yaml | kubectl apply --server-side --field-manager=unidesk-agentrun-secret-sync -f - >/dev/null", + "kubectl -n \"$namespace\" create secret generic \"$secret_name\" --from-file=\"$secret_key=$secret_file\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=unidesk-agentrun-secret-sync -f - >/dev/null", + "rm -f \"$secret_file\"", + "kubectl -n \"$namespace\" get secret \"$secret_name\" -o json > \"$tmp_dir/secret.json\"", + "NAMESPACE=\"$namespace\" SECRET_NAME=\"$secret_name\" SECRET_KEY=\"$secret_key\" SECRET_JSON=\"$tmp_dir/secret.json\" node <<'NODE'", + "const fs = require('node:fs');", + "const crypto = require('node:crypto');", + "const secret = JSON.parse(fs.readFileSync(process.env.SECRET_JSON, 'utf8'));", + "const data = secret.data || {};", + "const raw = data[process.env.SECRET_KEY] || '';", + "const bytes = raw ? Buffer.from(raw, 'base64').length : 0;", + "const fingerprint = raw ? 'sha256:' + crypto.createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex') : null;", + "console.log(JSON.stringify({ ok: Boolean(raw), namespace: process.env.NAMESPACE, secret: process.env.SECRET_NAME, key: process.env.SECRET_KEY, valueBytes: bytes, fingerprint, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + function cleanupRunsPlanNodeScript(): string { return String.raw` const fs = require("node:fs");