chore(ops): split /api/health (liveness) from /api/ready (readiness)
Previously /api/health did deep dependency probes (postgres + redis + minio) and 503'd on any failure. That's readiness behavior, not liveness — a transient Redis/MinIO blip would tell the orchestrator to restart the pod when it should only be dropped from the load balancer. Make /api/health a thin liveness check (returns 200 unconditionally if the process is responding) and move the deep checks to a new /api/ready endpoint with the canonical Kubernetes-style 200/503 contract. Docker-compose healthchecks keep pointing at /api/health, which is now more conservative (no false-positive container restarts). Documenso/SMTP are intentionally not probed in /api/ready: each tenant configures its own credentials and a tenant misconfiguration shouldn't deadline the entire shared CRM. Also tighten the gdpr-bundle-builder casts: replace the scattered `as unknown as Record<string, unknown>` double-casts with a small `toJsonRow<T>()` helper that does the widen narrow→wide in one place with one cast hop instead of two. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,68 +1,15 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
|
||||||
import { redis } from '@/lib/redis';
|
|
||||||
import { minioClient } from '@/lib/minio';
|
|
||||||
import { env } from '@/lib/env';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
type CheckStatus = 'ok' | 'error';
|
/**
|
||||||
|
* Liveness probe — confirms the Next.js process is responding.
|
||||||
interface HealthChecks {
|
*
|
||||||
postgres: CheckStatus;
|
* Returns 200 unconditionally; if the process is wedged or has crashed
|
||||||
redis: CheckStatus;
|
* the request never lands here at all. Do NOT include database/Redis/MinIO
|
||||||
minio: CheckStatus;
|
* checks in this endpoint — a transient downstream blip should drop the
|
||||||
}
|
* pod from the load balancer (readiness), not restart the pod (liveness).
|
||||||
|
*
|
||||||
interface HealthResponse {
|
* For deep dependency checks, hit `/api/ready` instead.
|
||||||
status: 'healthy' | 'degraded';
|
*/
|
||||||
checks: HealthChecks;
|
export async function GET() {
|
||||||
timestamp: string;
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(): Promise<NextResponse<HealthResponse>> {
|
|
||||||
const checks: HealthChecks = {
|
|
||||||
postgres: 'error',
|
|
||||||
redis: 'error',
|
|
||||||
minio: 'error',
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.allSettled([
|
|
||||||
db
|
|
||||||
.execute(sql`SELECT 1`)
|
|
||||||
.then(() => {
|
|
||||||
checks.postgres = 'ok';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
checks.postgres = 'error';
|
|
||||||
}),
|
|
||||||
|
|
||||||
redis
|
|
||||||
.ping()
|
|
||||||
.then(() => {
|
|
||||||
checks.redis = 'ok';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
checks.redis = 'error';
|
|
||||||
}),
|
|
||||||
|
|
||||||
minioClient
|
|
||||||
.bucketExists(env.MINIO_BUCKET)
|
|
||||||
.then(() => {
|
|
||||||
checks.minio = 'ok';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
checks.minio = 'error';
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const allHealthy = Object.values(checks).every((s) => s === 'ok');
|
|
||||||
const status: HealthResponse['status'] = allHealthy ? 'healthy' : 'degraded';
|
|
||||||
|
|
||||||
const body: HealthResponse = {
|
|
||||||
status,
|
|
||||||
checks,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(body, { status: allHealthy ? 200 : 503 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/app/api/ready/route.ts
Normal file
82
src/app/api/ready/route.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { redis } from '@/lib/redis';
|
||||||
|
import { minioClient } from '@/lib/minio';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
|
||||||
|
type CheckStatus = 'ok' | 'error';
|
||||||
|
|
||||||
|
interface ReadyChecks {
|
||||||
|
postgres: CheckStatus;
|
||||||
|
redis: CheckStatus;
|
||||||
|
minio: CheckStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadyResponse {
|
||||||
|
status: 'ready' | 'degraded';
|
||||||
|
checks: ReadyChecks;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness probe — verifies that every backing service this process
|
||||||
|
* needs to serve traffic is reachable. A 503 should drop the pod from the
|
||||||
|
* load balancer until the next probe succeeds; it should not trigger a
|
||||||
|
* pod restart (that's what `/api/health` is for).
|
||||||
|
*
|
||||||
|
* Checks:
|
||||||
|
* - postgres: `SELECT 1` against the primary
|
||||||
|
* - redis: `PING`
|
||||||
|
* - minio: `bucketExists(<configured-bucket>)`
|
||||||
|
*
|
||||||
|
* Documenso + SMTP are intentionally not probed here: they're optional
|
||||||
|
* integrations, and each tenant configures its own credentials. A
|
||||||
|
* tenant-misconfigured Documenso instance shouldn't deadline the entire
|
||||||
|
* shared CRM.
|
||||||
|
*/
|
||||||
|
export async function GET(): Promise<NextResponse<ReadyResponse>> {
|
||||||
|
const checks: ReadyChecks = {
|
||||||
|
postgres: 'error',
|
||||||
|
redis: 'error',
|
||||||
|
minio: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
db
|
||||||
|
.execute(sql`SELECT 1`)
|
||||||
|
.then(() => {
|
||||||
|
checks.postgres = 'ok';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
checks.postgres = 'error';
|
||||||
|
}),
|
||||||
|
|
||||||
|
redis
|
||||||
|
.ping()
|
||||||
|
.then(() => {
|
||||||
|
checks.redis = 'ok';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
checks.redis = 'error';
|
||||||
|
}),
|
||||||
|
|
||||||
|
minioClient
|
||||||
|
.bucketExists(env.MINIO_BUCKET)
|
||||||
|
.then(() => {
|
||||||
|
checks.minio = 'ok';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
checks.minio = 'error';
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allReady = Object.values(checks).every((s) => s === 'ok');
|
||||||
|
const status: ReadyResponse['status'] = allReady ? 'ready' : 'degraded';
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ status, checks, timestamp: new Date().toISOString() },
|
||||||
|
{ status: allReady ? 200 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,25 +145,40 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
|||||||
clientId,
|
clientId,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
},
|
},
|
||||||
client: client as unknown as Record<string, unknown>,
|
// Drizzle row types contain non-`unknown` value types (Date, branded
|
||||||
contacts: contacts as unknown as Record<string, unknown>[],
|
// strings). The bundle is exported as JSON, so we widen to plain
|
||||||
addresses: addresses as unknown as Record<string, unknown>[],
|
// `Record<string, unknown>` here. `toJsonRow` performs the narrow → wide
|
||||||
|
// widening in a single, locally-typed step instead of the prior
|
||||||
|
// `as unknown as Record<string, unknown>` double-cast.
|
||||||
|
client: toJsonRow(client),
|
||||||
|
contacts: contacts.map(toJsonRow),
|
||||||
|
addresses: addresses.map(toJsonRow),
|
||||||
tags: tagJoins,
|
tags: tagJoins,
|
||||||
relationships: relationships as unknown as Record<string, unknown>[],
|
relationships: relationships.map(toJsonRow),
|
||||||
notes: notes as unknown as Record<string, unknown>[],
|
notes: notes.map(toJsonRow),
|
||||||
ownedYachts: ownedYachts as unknown as Record<string, unknown>[],
|
ownedYachts: ownedYachts.map(toJsonRow),
|
||||||
companyMemberships: membershipRows as unknown as Array<{
|
companyMemberships: membershipRows.map((row) => ({
|
||||||
membership: Record<string, unknown>;
|
membership: toJsonRow(row.membership),
|
||||||
company: Record<string, unknown>;
|
company: toJsonRow(row.company),
|
||||||
}>,
|
})),
|
||||||
interests: interestRows as unknown as Record<string, unknown>[],
|
interests: interestRows.map(toJsonRow),
|
||||||
reservations: reservationRows as unknown as Record<string, unknown>[],
|
reservations: reservationRows.map(toJsonRow),
|
||||||
invoices: invoiceRows as unknown as Record<string, unknown>[],
|
invoices: invoiceRows.map(toJsonRow),
|
||||||
documents: documentRows as unknown as Record<string, unknown>[],
|
documents: documentRows.map(toJsonRow),
|
||||||
auditTrail: auditRows as unknown as Record<string, unknown>[],
|
auditTrail: auditRows.map(toJsonRow),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widens a Drizzle row type to `Record<string, unknown>` for inclusion in
|
||||||
|
* the JSON bundle. Drizzle row types are narrower than the open record
|
||||||
|
* shape we want; this helper does the widening in one place rather than
|
||||||
|
* scattering double-casts across the call sites.
|
||||||
|
*/
|
||||||
|
function toJsonRow<T extends object>(row: T): Record<string, unknown> {
|
||||||
|
return row as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HTML rendering ──────────────────────────────────────────────────────────
|
// ─── HTML rendering ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function escapeHtml(s: unknown): string {
|
function escapeHtml(s: unknown): string {
|
||||||
@@ -217,7 +232,7 @@ export function renderBundleHtml(bundle: GdprBundle): string {
|
|||||||
tableSection('Client', [bundle.client]),
|
tableSection('Client', [bundle.client]),
|
||||||
tableSection('Contacts', bundle.contacts),
|
tableSection('Contacts', bundle.contacts),
|
||||||
tableSection('Addresses', bundle.addresses),
|
tableSection('Addresses', bundle.addresses),
|
||||||
tableSection('Tags', bundle.tags as unknown as Record<string, unknown>[]),
|
tableSection('Tags', bundle.tags.map(toJsonRow)),
|
||||||
tableSection('Relationships', bundle.relationships),
|
tableSection('Relationships', bundle.relationships),
|
||||||
tableSection('Notes', bundle.notes),
|
tableSection('Notes', bundle.notes),
|
||||||
tableSection('Owned yachts', bundle.ownedYachts),
|
tableSection('Owned yachts', bundle.ownedYachts),
|
||||||
|
|||||||
33
tests/integration/health-and-ready.test.ts
Normal file
33
tests/integration/health-and-ready.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { GET as healthGet } from '@/app/api/health/route';
|
||||||
|
import { GET as readyGet } from '@/app/api/ready/route';
|
||||||
|
|
||||||
|
describe('GET /api/health (liveness)', () => {
|
||||||
|
it('returns 200 + status=ok regardless of downstream dependency state', async () => {
|
||||||
|
const res = await healthGet();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/ready (readiness)', () => {
|
||||||
|
it('returns ready=200 when postgres + redis + minio all answer', async () => {
|
||||||
|
// The dev/test environment has all three reachable; this covers the
|
||||||
|
// happy path. The degraded path is not exercised here because
|
||||||
|
// simulating a down dep without leaking into other tests is awkward;
|
||||||
|
// the route's logic is intentionally trivial (Promise.allSettled +
|
||||||
|
// every-ok check) and worth covering at the unit level only.
|
||||||
|
const res = await readyGet();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('ready');
|
||||||
|
expect(body.checks).toEqual({
|
||||||
|
postgres: 'ok',
|
||||||
|
redis: 'ok',
|
||||||
|
minio: 'ok',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user