diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml index 3fe9530..0bdda30 100644 --- a/deploy/compose.production.yml +++ b/deploy/compose.production.yml @@ -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 diff --git a/deploy/compose.staging.yml b/deploy/compose.staging.yml index e2ecb61..92425af 100644 --- a/deploy/compose.staging.yml +++ b/deploy/compose.staging.yml @@ -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 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b159d51..4f48034 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/src/lib/server/site-access.test.ts b/src/lib/server/site-access.test.ts index 72d76a6..20b6bf6 100644 --- a/src/lib/server/site-access.test.ts +++ b/src/lib/server/site-access.test.ts @@ -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) => { diff --git a/src/lib/server/site-access.ts b/src/lib/server/site-access.ts index ac0c79d..133b4eb 100644 --- a/src/lib/server/site-access.ts +++ b/src/lib/server/site-access.ts @@ -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/') || diff --git a/src/routes/api/health.test.ts b/src/routes/api/health.test.ts new file mode 100644 index 0000000..5f85c3b --- /dev/null +++ b/src/routes/api/health.test.ts @@ -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'); + }); +}); diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..5c6e784 --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -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 }); +}