feat(secrets): report real backend health instead of a hard-coded tick
`mcpctl status` derived its Secrets verdict entirely from `tokenMeta.lastRotationError`. The rotator writes that field only for `auth: 'token'` backends (SecretBackendRotator.isRotatable), so a `kubernetes`-auth backend never wrote it and the line rendered a green tick unconditionally — including with OpenBao sealed, unreachable, or answering 403 to every read. The one signal we had was structurally incapable of going red for the backend we actually run. New `GET /api/v1/secretbackends/:id/health` reports two signals, kept separate on purpose: live — reachable at all? (unauthenticated sys/health) ready — can we read through it? (uses our credentials) live-but-not-ready is the exact shape of a re-initialised OpenBao handing back valid-looking tokens that grant nothing; collapsing both into one boolean is what hid that for four days. Needs no RBAC mapping — it falls through to the generic `secretbackends` resource, so a GET is `view:secretbackends`. `mcpctl status` now renders four states — reachable / degraded (serving N cached secrets) / unreachable / auth failed — with rotation error demoted to a trailing clause rather than the verdict. A failed probe renders "? unknown", never green: not knowing is not health. JSON output carries the same probe, so scripts stop being told every k8s-auth backend is fine. Also adds a boot-time cache warm. The stale-while-error cache can only absorb an outage for secrets it has already seen, so a cold mcpd during a backend outage still fails; resolving each running server's refs once at startup closes that for the common case. Best-effort and deliberately partial — if the backend is also down at boot this is a no-op and instances fail loudly, which is correct. Persisting last-known-good to Postgres or disk would just be plaintext-at-rest again. Tests: 15 new. The five status assertions were confirmed to fail against the old rotation-only logic before being kept. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
This commit is contained in:
98
src/mcpd/tests/secret-backend-health-route.test.ts
Normal file
98
src/mcpd/tests/secret-backend-health-route.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { SecretBackend } from '@prisma/client';
|
||||
import { registerSecretBackendHealthRoutes } from '../src/routes/secret-backend-health.js';
|
||||
import { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
import type { ISecretBackendRepository } from '../src/repositories/secret-backend.repository.js';
|
||||
import type { SecretBackendDriver } from '../src/services/secret-backends/types.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
afterEach(async () => { await app?.close(); });
|
||||
|
||||
function backendRow(overrides: Partial<SecretBackend> = {}): SecretBackend {
|
||||
return {
|
||||
id: 'b1', name: 'bao-k8s', type: 'openbao',
|
||||
config: { url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
|
||||
isDefault: true, description: '', version: 1,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as SecretBackend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the route over a service whose driver is stubbed. We override
|
||||
* `driverFor` rather than the factory so the test drives the two probes
|
||||
* directly — the point here is the route's reporting, not driver internals.
|
||||
*/
|
||||
async function buildApp(
|
||||
probes: Pick<SecretBackendDriver, 'healthCheck' | 'authCheck'>,
|
||||
row: SecretBackend = backendRow(),
|
||||
): Promise<FastifyInstance> {
|
||||
const repo = {
|
||||
findById: vi.fn(async (id: string) => (id === row.id ? row : null)),
|
||||
} as unknown as ISecretBackendRepository;
|
||||
const svc = new SecretBackendService(repo, {
|
||||
plaintext: { listAllPlaintext: async () => [] },
|
||||
secretRefResolver: { resolve: async () => 'tok' },
|
||||
});
|
||||
vi.spyOn(svc, 'driverFor').mockReturnValue({ kind: 'openbao', ...probes } as SecretBackendDriver);
|
||||
vi.spyOn(svc, 'cacheStatsFor').mockReturnValue({ entries: 3, servingStale: 0, oldestStaleSince: undefined });
|
||||
|
||||
const a = Fastify();
|
||||
registerSecretBackendHealthRoutes(a, svc);
|
||||
await a.ready();
|
||||
return a;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/secretbackends/:id/health', () => {
|
||||
it('reports live+ready when the backend is fully working', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true, detail: 'active' }),
|
||||
authCheck: async () => ({ ok: true, detail: 'readable at secret/mcpctl' }),
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ backend: 'bao-k8s', live: true, ready: true });
|
||||
});
|
||||
|
||||
it('reports NOT live when OpenBao is sealed — regardless of rotation state', async () => {
|
||||
// The bug this endpoint exists for: a kubernetes-auth backend never writes
|
||||
// tokenMeta.lastRotationError, so the old status line stayed green here.
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: false, detail: 'sealed' }),
|
||||
authCheck: async () => ({ ok: true, detail: 'should not be consulted' }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body.live).toBe(false);
|
||||
expect(body.liveDetail).toBe('sealed');
|
||||
expect(body.ready).toBe(false);
|
||||
expect(body.readyDetail).toMatch(/not probed/);
|
||||
expect(body.rotation.lastRotationError).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes reachable-but-unusable (revoked grants) from unreachable', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true, detail: 'active' }),
|
||||
authCheck: async () => ({ ok: false, detail: 'OpenBao list: HTTP 403 permission denied' }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body).toMatchObject({ live: true, ready: false });
|
||||
expect(body.readyDetail).toMatch(/403/);
|
||||
});
|
||||
|
||||
it('surfaces cache state so degraded serving is visible', async () => {
|
||||
app = await buildApp({
|
||||
healthCheck: async () => ({ ok: true }),
|
||||
authCheck: async () => ({ ok: true }),
|
||||
});
|
||||
const body = (await app.inject({ method: 'GET', url: '/api/v1/secretbackends/b1/health' })).json();
|
||||
expect(body.cache).toMatchObject({ entries: 3, servingStale: 0 });
|
||||
});
|
||||
|
||||
it('404s for an unknown backend', async () => {
|
||||
app = await buildApp({ healthCheck: async () => ({ ok: true }), authCheck: async () => ({ ok: true }) });
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/secretbackends/nope/health' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
66
src/mcpd/tests/warm-secret-cache.test.ts
Normal file
66
src/mcpd/tests/warm-secret-cache.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { warmSecretCache } from '../src/bootstrap/warm-secret-cache.js';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import type { SecretService } from '../src/services/secret.service.js';
|
||||
|
||||
function prismaWith(servers: Array<{ name: string; env: unknown }>): PrismaClient {
|
||||
return { mcpServer: { findMany: vi.fn(async () => servers) } } as unknown as PrismaClient;
|
||||
}
|
||||
const noLog = { info: (): void => undefined, warn: (): void => undefined };
|
||||
|
||||
const envRef = (name: string, secret: string, key: string): unknown =>
|
||||
({ name, valueFrom: { secretRef: { name: secret, key } } });
|
||||
|
||||
describe('warmSecretCache', () => {
|
||||
it('resolves every distinct secret ref exactly once', async () => {
|
||||
const prisma = prismaWith([
|
||||
{ name: 'gitea', env: [envRef('GITEA_ACCESS_TOKEN', 'gitea-creds', 'GITEA_ACCESS_TOKEN')] },
|
||||
// Two servers sharing one secret must not cost two reads.
|
||||
{ name: 'a', env: [envRef('T', 'shared', 'TOKEN')] },
|
||||
{ name: 'b', env: [envRef('T', 'shared', 'TOKEN')] },
|
||||
]);
|
||||
const resolve = vi.fn(async () => 'value');
|
||||
const result = await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog);
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ warmed: 2, failed: 0 });
|
||||
});
|
||||
|
||||
it('ignores inline env values', async () => {
|
||||
const prisma = prismaWith([{ name: 's', env: [{ name: 'PLAIN', value: 'x' }] }]);
|
||||
const resolve = vi.fn(async () => 'v');
|
||||
expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
|
||||
.toEqual({ warmed: 0, failed: 0 });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never throws when the backend is down — startup must not block', async () => {
|
||||
const prisma = prismaWith([
|
||||
{ name: 'a', env: [envRef('T', 's1', 'K')] },
|
||||
{ name: 'b', env: [envRef('T', 's2', 'K')] },
|
||||
]);
|
||||
const resolve = vi.fn(async () => { throw new Error('bao unreachable'); });
|
||||
await expect(warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
|
||||
.resolves.toEqual({ warmed: 0, failed: 2 });
|
||||
});
|
||||
|
||||
it('keeps going after one bad reference', async () => {
|
||||
const prisma = prismaWith([
|
||||
{ name: 'a', env: [envRef('T', 'missing', 'K')] },
|
||||
{ name: 'b', env: [envRef('T', 'present', 'K')] },
|
||||
]);
|
||||
const resolve = vi.fn(async (n: string) => {
|
||||
if (n === 'missing') throw new Error('no such secret');
|
||||
return 'v';
|
||||
});
|
||||
expect(await warmSecretCache(prisma, { resolve } as unknown as SecretService, noLog))
|
||||
.toEqual({ warmed: 1, failed: 1 });
|
||||
});
|
||||
|
||||
it('only considers servers with replicas > 0', async () => {
|
||||
const prisma = prismaWith([]);
|
||||
await warmSecretCache(prisma, { resolve: vi.fn() } as unknown as SecretService, noLog);
|
||||
const findMany = (prisma.mcpServer.findMany as unknown as ReturnType<typeof vi.fn>);
|
||||
expect(findMany.mock.calls[0]?.[0]).toMatchObject({ where: { replicas: { gt: 0 } } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user