Add application health endpoint
This commit is contained in:
@@ -29,7 +29,7 @@ services:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:3000/api/health/database').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
- fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:3000/api/health/database').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
- fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ The workflow:
|
||||
4. creates a timestamped JSON backup if the staging database exists;
|
||||
5. applies additive, repeatable migrations;
|
||||
6. starts the application and scheduler with the new image;
|
||||
7. waits for `/api/health/database` to report healthy;
|
||||
7. waits for `/api/health` to report healthy;
|
||||
8. retains the logs and exits unsuccessfully if health verification fails.
|
||||
|
||||
The outage covers backup, migration, and container startup. Migrations may also
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('isLaunchGateExempt', () => {
|
||||
'/contact',
|
||||
'/auth/discord',
|
||||
'/auth/discord/callback',
|
||||
'/api/health',
|
||||
'/api/health/database',
|
||||
'/api/internal/notifications',
|
||||
'/_app/immutable/entry/start.js',
|
||||
@@ -88,7 +89,6 @@ describe('isLaunchGateExempt', () => {
|
||||
'/coming-soon/preview',
|
||||
'/sign-in/help',
|
||||
'/auth',
|
||||
'/api/health',
|
||||
'/api/internal',
|
||||
'/favicon.png'
|
||||
])('does not exempt %s', (pathname) => {
|
||||
|
||||
@@ -64,6 +64,7 @@ export function isLaunchGateExempt(pathname: string) {
|
||||
pathname === '/dmca' ||
|
||||
pathname === '/contact' ||
|
||||
pathname.startsWith('/auth/') ||
|
||||
pathname === '/api/health' ||
|
||||
pathname.startsWith('/api/health/') ||
|
||||
pathname.startsWith('/api/internal/') ||
|
||||
pathname.startsWith('/_app/') ||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: { healthy: 1 } as { healthy?: number },
|
||||
err: null as Error | null
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => {
|
||||
if (state.err) {
|
||||
throw state.err;
|
||||
}
|
||||
|
||||
return {
|
||||
prepare: async () => ({
|
||||
get: async () => state.row
|
||||
})
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
import { GET } from './health/+server';
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
let originalBuildNumber: string | undefined;
|
||||
let originalCommitSha: string | undefined;
|
||||
let originalImage: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
state.row = { healthy: 1 };
|
||||
state.err = null;
|
||||
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
|
||||
originalCommitSha = process.env.SHD_COMMIT_SHA;
|
||||
originalImage = process.env.SHD_IMAGE;
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
delete process.env.SHD_IMAGE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBuildNumber === undefined) {
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
} else {
|
||||
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
|
||||
}
|
||||
|
||||
if (originalCommitSha === undefined) {
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
} else {
|
||||
process.env.SHD_COMMIT_SHA = originalCommitSha;
|
||||
}
|
||||
|
||||
if (originalImage === undefined) {
|
||||
delete process.env.SHD_IMAGE;
|
||||
} else {
|
||||
process.env.SHD_IMAGE = originalImage;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns healthy status and metadata when checks pass', async () => {
|
||||
process.env.SHD_BUILD_NUMBER = '123';
|
||||
process.env.SHD_COMMIT_SHA = 'abc1234';
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'ok',
|
||||
status_code: 200,
|
||||
ready: true,
|
||||
service: 'app',
|
||||
version: expect.any(String),
|
||||
build_number: '123',
|
||||
build_revision: 'abc1234',
|
||||
checks: {
|
||||
database: {
|
||||
status: 'ok',
|
||||
error: null
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns degraded status and 503 when DB check fails', async () => {
|
||||
state.err = new Error('database unavailable');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'degraded',
|
||||
status_code: 503,
|
||||
ready: false,
|
||||
checks: {
|
||||
database: {
|
||||
status: 'unhealthy',
|
||||
error: 'database unavailable'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('uses image tag as revision when commit sha is absent', async () => {
|
||||
process.env.SHD_IMAGE = 'shd-build-production:7';
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.build_revision).toBe('7');
|
||||
expect(payload.build_number).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { version as appVersion } from '../../../../package.json';
|
||||
|
||||
function configuredBuildNumber() {
|
||||
const raw = process.env.SHD_BUILD_NUMBER?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return '0';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function inferredRevision() {
|
||||
const direct = process.env.SHD_COMMIT_SHA?.trim();
|
||||
if (direct) return direct;
|
||||
|
||||
const image = process.env.SHD_IMAGE?.trim();
|
||||
if (!image) return null;
|
||||
|
||||
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
|
||||
return tag ? tag : null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const startedAt = new Date().toISOString();
|
||||
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
|
||||
let databaseError: string | null = null;
|
||||
|
||||
try {
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare('SELECT 1 AS healthy');
|
||||
const row = await statement.get();
|
||||
if (row?.healthy !== 1) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = 'health-check query returned unexpected value';
|
||||
}
|
||||
} catch (err) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const ready = databaseStatus === 'ok';
|
||||
const responsePayload = {
|
||||
status: 'ok',
|
||||
service: 'app',
|
||||
status_code: 200,
|
||||
version: appVersion,
|
||||
build_revision: inferredRevision(),
|
||||
build_number: configuredBuildNumber(),
|
||||
ready,
|
||||
uptime_ms: process.uptime() * 1000,
|
||||
timestamp: startedAt,
|
||||
checks: {
|
||||
database: {
|
||||
status: databaseStatus,
|
||||
error: databaseError
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
responsePayload.status = 'degraded';
|
||||
responsePayload.status_code = 503;
|
||||
}
|
||||
|
||||
return json(responsePayload, { status: ready ? 200 : 503 });
|
||||
}
|
||||
Reference in New Issue
Block a user