feat: sync AgentRun lane database secret

This commit is contained in:
Codex
2026-06-13 04:32:07 +00:00
parent 687310d83c
commit 90fae0c1c4
+169 -1
View File
@@ -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-<short-sha>",
@@ -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 <action> [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-<short-sha>",
" 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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, string> {
const result = new Map<string, string>();
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");