feat: sync AgentRun lane database secret
This commit is contained in:
+169
-1
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user