Add application health endpoint
This commit is contained in:
@@ -29,7 +29,7 @@ services:
|
|||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 8
|
retries: 8
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 8
|
retries: 8
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ The workflow:
|
|||||||
4. creates a timestamped JSON backup if the staging database exists;
|
4. creates a timestamped JSON backup if the staging database exists;
|
||||||
5. applies additive, repeatable migrations;
|
5. applies additive, repeatable migrations;
|
||||||
6. starts the application and scheduler with the new image;
|
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.
|
8. retains the logs and exits unsuccessfully if health verification fails.
|
||||||
|
|
||||||
The outage covers backup, migration, and container startup. Migrations may also
|
The outage covers backup, migration, and container startup. Migrations may also
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe('isLaunchGateExempt', () => {
|
|||||||
'/contact',
|
'/contact',
|
||||||
'/auth/discord',
|
'/auth/discord',
|
||||||
'/auth/discord/callback',
|
'/auth/discord/callback',
|
||||||
|
'/api/health',
|
||||||
'/api/health/database',
|
'/api/health/database',
|
||||||
'/api/internal/notifications',
|
'/api/internal/notifications',
|
||||||
'/_app/immutable/entry/start.js',
|
'/_app/immutable/entry/start.js',
|
||||||
@@ -88,7 +89,6 @@ describe('isLaunchGateExempt', () => {
|
|||||||
'/coming-soon/preview',
|
'/coming-soon/preview',
|
||||||
'/sign-in/help',
|
'/sign-in/help',
|
||||||
'/auth',
|
'/auth',
|
||||||
'/api/health',
|
|
||||||
'/api/internal',
|
'/api/internal',
|
||||||
'/favicon.png'
|
'/favicon.png'
|
||||||
])('does not exempt %s', (pathname) => {
|
])('does not exempt %s', (pathname) => {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function isLaunchGateExempt(pathname: string) {
|
|||||||
pathname === '/dmca' ||
|
pathname === '/dmca' ||
|
||||||
pathname === '/contact' ||
|
pathname === '/contact' ||
|
||||||
pathname.startsWith('/auth/') ||
|
pathname.startsWith('/auth/') ||
|
||||||
|
pathname === '/api/health' ||
|
||||||
pathname.startsWith('/api/health/') ||
|
pathname.startsWith('/api/health/') ||
|
||||||
pathname.startsWith('/api/internal/') ||
|
pathname.startsWith('/api/internal/') ||
|
||||||
pathname.startsWith('/_app/') ||
|
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