From 6ac79de8a42dc1e56a80662fed1736daa16b3240 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 24 Apr 2026 01:01:40 +0100 Subject: [PATCH] feat(secrets): one-shot startup backfill for keyNames on existing rows 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. --- src/mcpd/src/bootstrap/secret-key-names.ts | 64 ++++++++++++++++++++++ src/mcpd/src/main.ts | 12 ++++ 2 files changed, 76 insertions(+) create mode 100644 src/mcpd/src/bootstrap/secret-key-names.ts diff --git a/src/mcpd/src/bootstrap/secret-key-names.ts b/src/mcpd/src/bootstrap/secret-key-names.ts new file mode 100644 index 0000000..f47f86c --- /dev/null +++ b/src/mcpd/src/bootstrap/secret-key-names.ts @@ -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 { + 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; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 6067042..5d9ee51 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { 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 () => {