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 { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
|
||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.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');
|
||||
});
|
||||
|
||||
// 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
|
||||
setupGracefulShutdown(app, {
|
||||
disconnectDb: async () => {
|
||||
|
||||
Reference in New Issue
Block a user