feat(secrets): one-shot startup backfill for keyNames on existing rows
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m8s
CI/CD / typecheck (push) Successful in 2m20s
CI/CD / build (push) Successful in 2m49s
CI/CD / smoke (push) Failing after 3m16s
CI/CD / publish (push) Has been skipped

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:
Michal
2026-04-24 01:01:40 +01:00
parent 9a808877b5
commit 6ac79de8a4
2 changed files with 76 additions and 0 deletions

View 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;
}

View File

@@ -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 () => {