mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 05:48:39 +00:00
feat(secrets): replace migrate flow with audit/configure/apply
This commit is contained in:
committed by
Peter Steinberger
parent
8944b75e16
commit
f413e314b9
@@ -1,58 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import { confirm } from "@clack/prompts";
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
rollbackSecretsMigration,
|
||||
runSecretsMigration,
|
||||
type SecretsMigrationRollbackResult,
|
||||
type SecretsMigrationRunResult,
|
||||
} from "../secrets/migrate.js";
|
||||
import { runSecretsApply } from "../secrets/apply.js";
|
||||
import { resolveSecretsAuditExitCode, runSecretsAudit } from "../secrets/audit.js";
|
||||
import { runSecretsConfigureInteractive } from "../secrets/configure.js";
|
||||
import { isSecretsApplyPlan, type SecretsApplyPlan } from "../secrets/plan.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
|
||||
type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean };
|
||||
type SecretsMigrateOptions = {
|
||||
write?: boolean;
|
||||
rollback?: string;
|
||||
scrubEnv?: boolean;
|
||||
type SecretsAuditOptions = {
|
||||
check?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
type SecretsConfigureOptions = {
|
||||
apply?: boolean;
|
||||
yes?: boolean;
|
||||
planOut?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
type SecretsApplyOptions = {
|
||||
from: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function printMigrationResult(
|
||||
result: SecretsMigrationRunResult | SecretsMigrationRollbackResult,
|
||||
json: boolean,
|
||||
): void {
|
||||
if (json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
function readPlanFile(pathname: string): SecretsApplyPlan {
|
||||
const raw = fs.readFileSync(pathname, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isSecretsApplyPlan(parsed)) {
|
||||
throw new Error(`Invalid secrets plan file: ${pathname}`);
|
||||
}
|
||||
|
||||
if ("restoredFiles" in result) {
|
||||
defaultRuntime.log(
|
||||
`Secrets rollback complete for backup ${result.backupId}. Restored ${result.restoredFiles} file(s), deleted ${result.deletedFiles} file(s).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.mode === "dry-run") {
|
||||
if (!result.changed) {
|
||||
defaultRuntime.log("Secrets migrate dry run: no changes needed.");
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
`Secrets migrate dry run: ${result.changedFiles.length} file(s) would change, ${result.counters.secretsWritten} secret value(s) would move to ${result.secretsFilePath}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.changed) {
|
||||
defaultRuntime.log("Secrets migrate: no changes applied.");
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
`Secrets migrated. Backup: ${result.backupId}. Moved ${result.counters.secretsWritten} secret value(s) into ${result.secretsFilePath}.`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function registerSecretsCli(program: Command) {
|
||||
@@ -94,25 +76,152 @@ export function registerSecretsCli(program: Command) {
|
||||
});
|
||||
|
||||
secrets
|
||||
.command("migrate")
|
||||
.description("Migrate plaintext secrets to file-backed SecretRefs")
|
||||
.option("--write", "Apply migration changes (default is dry-run)", false)
|
||||
.option("--rollback <backup-id>", "Rollback a previous migration backup id")
|
||||
.option("--no-scrub-env", "Keep matching plaintext values in ~/.openclaw/.env")
|
||||
.command("audit")
|
||||
.description("Audit plaintext secrets, unresolved refs, and precedence drift")
|
||||
.option("--check", "Exit non-zero when findings are present", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: SecretsMigrateOptions) => {
|
||||
.action(async (opts: SecretsAuditOptions) => {
|
||||
try {
|
||||
if (typeof opts.rollback === "string" && opts.rollback.trim()) {
|
||||
const result = await rollbackSecretsMigration({ backupId: opts.rollback.trim() });
|
||||
printMigrationResult(result, Boolean(opts.json));
|
||||
return;
|
||||
const report = await runSecretsAudit();
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
defaultRuntime.log(
|
||||
`Secrets audit: ${report.status}. plaintext=${report.summary.plaintextCount}, unresolved=${report.summary.unresolvedRefCount}, shadowed=${report.summary.shadowedRefCount}, legacy=${report.summary.legacyResidueCount}.`,
|
||||
);
|
||||
if (report.findings.length > 0) {
|
||||
for (const finding of report.findings.slice(0, 20)) {
|
||||
defaultRuntime.log(
|
||||
`- [${finding.code}] ${finding.file}:${finding.jsonPath} ${finding.message}`,
|
||||
);
|
||||
}
|
||||
if (report.findings.length > 20) {
|
||||
defaultRuntime.log(`... ${report.findings.length - 20} more finding(s).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const exitCode = resolveSecretsAuditExitCode(report, Boolean(opts.check));
|
||||
if (exitCode !== 0) {
|
||||
defaultRuntime.exit(exitCode);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(2);
|
||||
}
|
||||
});
|
||||
|
||||
secrets
|
||||
.command("configure")
|
||||
.description("Interactive SecretRef helper with preflight validation")
|
||||
.option("--apply", "Apply changes immediately after preflight", false)
|
||||
.option("--yes", "Skip apply confirmation prompt", false)
|
||||
.option("--plan-out <path>", "Write generated plan JSON to a file")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: SecretsConfigureOptions) => {
|
||||
try {
|
||||
const configured = await runSecretsConfigureInteractive();
|
||||
if (opts.planOut) {
|
||||
fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8");
|
||||
}
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
plan: configured.plan,
|
||||
preflight: configured.preflight,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.log(
|
||||
`Preflight: changed=${configured.preflight.changed}, files=${configured.preflight.changedFiles.length}, warnings=${configured.preflight.warningCount}.`,
|
||||
);
|
||||
if (configured.preflight.warningCount > 0) {
|
||||
for (const warning of configured.preflight.warnings) {
|
||||
defaultRuntime.log(`- warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
defaultRuntime.log(`Plan targets: ${configured.plan.targets.length}`);
|
||||
if (opts.planOut) {
|
||||
defaultRuntime.log(`Plan written to ${opts.planOut}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runSecretsMigration({
|
||||
write: Boolean(opts.write),
|
||||
scrubEnv: opts.scrubEnv ?? true,
|
||||
let shouldApply = Boolean(opts.apply);
|
||||
if (!shouldApply && !opts.json) {
|
||||
const approved = await confirm({
|
||||
message: "Apply this plan now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (typeof approved === "boolean") {
|
||||
shouldApply = approved;
|
||||
}
|
||||
}
|
||||
if (shouldApply) {
|
||||
const needsIrreversiblePrompt = Boolean(opts.apply);
|
||||
if (needsIrreversiblePrompt && !opts.yes && !opts.json) {
|
||||
const confirmed = await confirm({
|
||||
message:
|
||||
"This migration is one-way for migrated plaintext values. Continue with apply?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (confirmed !== true) {
|
||||
defaultRuntime.log("Apply cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await runSecretsApply({
|
||||
plan: configured.plan,
|
||||
write: true,
|
||||
});
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
result.changed
|
||||
? `Secrets applied. Updated ${result.changedFiles.length} file(s).`
|
||||
: "Secrets apply: no changes.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
secrets
|
||||
.command("apply")
|
||||
.description("Apply a previously generated secrets plan")
|
||||
.requiredOption("--from <path>", "Path to plan JSON")
|
||||
.option("--dry-run", "Validate/preflight only", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: SecretsApplyOptions) => {
|
||||
try {
|
||||
const plan = readPlanFile(opts.from);
|
||||
const result = await runSecretsApply({
|
||||
plan,
|
||||
write: !opts.dryRun,
|
||||
});
|
||||
printMigrationResult(result, Boolean(opts.json));
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
defaultRuntime.log(
|
||||
result.changed
|
||||
? `Secrets apply dry run: ${result.changedFiles.length} file(s) would change.`
|
||||
: "Secrets apply dry run: no changes.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
result.changed
|
||||
? `Secrets applied. Updated ${result.changedFiles.length} file(s).`
|
||||
: "Secrets apply: no changes.",
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user