feat(secrets): one-shot startup backfill for keyNames on existing rows
Some checks failed
Some checks failed
Lazy backfill in SecretService.getById covers per-row retries, but list
views still show 'KEYS: -' until each row is described. New
backfillSecretKeyNames bootstrap runs once at startup, finds Secrets
where keyNames=[] AND data={} (i.e. backend-stored, pre-existing rows),
calls resolveData to learn the keys, persists. Sequential to be kind to
the upstream backend on cold start. Idempotent + non-fatal.
This commit is contained in:
64
src/mcpd/src/bootstrap/secret-key-names.ts
Normal file
64
src/mcpd/src/bootstrap/secret-key-names.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* One-shot backfill for Secret.keyNames on startup.
|
||||||
|
*
|
||||||
|
* `keyNames` is populated on every write path going forward, but rows that
|
||||||
|
* pre-date the column (or that were migrated to a non-plaintext backend
|
||||||
|
* before the column existed) carry an empty array. Without this we'd show
|
||||||
|
* `KEYS: -` in `mcpctl get secrets` until each row is individually
|
||||||
|
* described — confusing UX.
|
||||||
|
*
|
||||||
|
* Run once per startup. Idempotent: any row whose keyNames is non-empty is
|
||||||
|
* skipped. Errors are non-fatal — the lazy backfill in
|
||||||
|
* `SecretService.getById` will retry per-row on the next describe.
|
||||||
|
*/
|
||||||
|
import type { PrismaClient, Secret } from '@prisma/client';
|
||||||
|
import type { SecretService } from '../services/secret.service.js';
|
||||||
|
|
||||||
|
export interface BackfillResult {
|
||||||
|
scanned: number;
|
||||||
|
populated: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backfillSecretKeyNames(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
secretService: SecretService,
|
||||||
|
log?: { info: (m: string) => void; warn: (m: string) => void },
|
||||||
|
): Promise<BackfillResult> {
|
||||||
|
const out: BackfillResult = { scanned: 0, populated: 0, failed: 0 };
|
||||||
|
|
||||||
|
// Find rows that need backfill: empty keyNames AND empty data (i.e. the
|
||||||
|
// values live in a remote backend, so we can't derive keys without a
|
||||||
|
// backend read). Plaintext-backed rows are handled by the lazy path
|
||||||
|
// (cheap — no IO).
|
||||||
|
const rows = await prisma.secret.findMany({
|
||||||
|
where: {
|
||||||
|
keyNames: { equals: [] },
|
||||||
|
data: { equals: {} },
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, backendId: true, data: true, externalRef: true, keyNames: true, version: true, createdAt: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
out.scanned = rows.length;
|
||||||
|
if (rows.length === 0) return out;
|
||||||
|
|
||||||
|
// Sequential rather than parallel — being kind to the upstream backend on
|
||||||
|
// first cold start. 9 secrets * ~50ms = 0.5s; fine.
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const data = await secretService.resolveData(row as Secret);
|
||||||
|
const keys = Object.keys(data).sort();
|
||||||
|
if (keys.length === 0) continue;
|
||||||
|
await prisma.secret.update({ where: { id: row.id }, data: { keyNames: keys } });
|
||||||
|
out.populated++;
|
||||||
|
} catch (err) {
|
||||||
|
out.failed++;
|
||||||
|
if (log !== undefined) {
|
||||||
|
log.warn(`secret-keynames-backfill: ${row.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (log !== undefined && out.populated > 0) {
|
||||||
|
log.info(`secret-keynames-backfill: populated ${String(out.populated)}/${String(out.scanned)} secret(s)`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { SecretBackendRepository } from './repositories/secret-backend.repositor
|
|||||||
import { SecretBackendService } from './services/secret-backend.service.js';
|
import { SecretBackendService } from './services/secret-backend.service.js';
|
||||||
import { SecretMigrateService } from './services/secret-migrate.service.js';
|
import { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||||
|
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
|
||||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
||||||
import { SecretBackendRotator } from './services/secret-backend-rotator.service.js';
|
import { SecretBackendRotator } from './services/secret-backend-rotator.service.js';
|
||||||
@@ -696,6 +697,17 @@ async function main(): Promise<void> {
|
|||||||
app.log.error({ err }, 'secret-backend rotator loop failed to start');
|
app.log.error({ err }, 'secret-backend rotator loop failed to start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One-shot: populate Secret.keyNames for any backend-stored secrets that
|
||||||
|
// pre-date the column. Idempotent + non-fatal — the lazy backfill in
|
||||||
|
// SecretService.getById covers the per-row retry case.
|
||||||
|
backfillSecretKeyNames(
|
||||||
|
prisma,
|
||||||
|
secretService,
|
||||||
|
{ info: (m) => app.log.info(m), warn: (m) => app.log.warn(m) },
|
||||||
|
).catch((err: unknown) => {
|
||||||
|
app.log.error({ err }, 'secret keyNames backfill failed');
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
setupGracefulShutdown(app, {
|
setupGracefulShutdown(app, {
|
||||||
disconnectDb: async () => {
|
disconnectDb: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user