diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index 9bac826aceb7d..46d10f71af171 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -119,11 +119,17 @@ export abstract class AbstractServer { protected setupPushServer() {} private async setupHealthCheck() { - // health check should not care about DB connections + // main health check should not care about DB connections this.app.get('/healthz', async (_req, res) => { res.send({ status: 'ok' }); }); + this.app.get('/healthz/readiness', async (_req, res) => { + return Db.connectionState.connected && Db.connectionState.migrated + ? res.status(200).send({ status: 'ok' }) + : res.status(503).send({ status: 'error' }); + }); + const { connectionState } = Db; this.app.use((_req, res, next) => { if (connectionState.connected) { diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 7db79b850874f..c1cff69330216 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -172,6 +172,12 @@ export class Worker extends BaseCommand { const server = http.createServer(app); + app.get('/healthz/readiness', async (_req, res) => { + return Db.connectionState.connected && Db.connectionState.migrated + ? res.status(200).send({ status: 'ok' }) + : res.status(503).send({ status: 'error' }); + }); + app.get( '/healthz', diff --git a/packages/cli/test/integration/healthcheck.controller.test.ts b/packages/cli/test/integration/healthcheck.controller.test.ts new file mode 100644 index 0000000000000..61a8f164bb18b --- /dev/null +++ b/packages/cli/test/integration/healthcheck.controller.test.ts @@ -0,0 +1,22 @@ +import * as testDb from './shared/test-db'; +import { setupTestServer } from '@test-integration/utils'; + +const testServer = setupTestServer({ endpointGroups: ['health'] }); + +describe('HealthcheckController', () => { + it('should return ok when DB is connected and migrated', async () => { + const response = await testServer.restlessAgent.get('/healthz/readiness'); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + + it('should return error when DB is not connected', async () => { + await testDb.terminate(); + + const response = await testServer.restlessAgent.get('/healthz/readiness'); + + expect(response.statusCode).toBe(503); + expect(response.body).toEqual({ status: 'error' }); + }); +}); diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index 06b1adb962500..1bb6944911944 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -39,11 +39,16 @@ export async function init() { await Db.migrate(); } +export function isReady() { + return Db.connectionState.connected && Db.connectionState.migrated; +} + /** * Drop test DB, closing bootstrap connection if existing. */ export async function terminate() { await Db.close(); + Db.connectionState.connected = false; } // Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 60fb6860824c3..e285c5aab67e1 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -10,6 +10,7 @@ import type { LicenseMocker } from './license'; import type { Project } from '@/databases/entities/project'; type EndpointGroup = + | 'health' | 'me' | 'users' | 'auth' @@ -54,6 +55,7 @@ export interface TestServer { authAgentFor: (user: User) => TestAgent; publicApiAgentFor: (user: User) => TestAgent; authlessAgent: TestAgent; + restlessAgent: TestAgent; license: LicenseMocker; } diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index a3cac9b4134c8..b2b86daefcf3c 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -44,9 +44,16 @@ function prefix(pathSegment: string) { } const browserId = 'test-browser-id'; -function createAgent(app: express.Application, options?: { auth: boolean; user: User }) { +function createAgent( + app: express.Application, + options?: { auth: boolean; user?: User; noRest?: boolean }, +) { const agent = request.agent(app); - void agent.use(prefix(REST_PATH_SEGMENT)); + + const withRestSegment = !options?.noRest; + + if (withRestSegment) void agent.use(prefix(REST_PATH_SEGMENT)); + if (options?.auth && options?.user) { const token = Container.get(AuthService).issueJWT(options.user, browserId); agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`); @@ -89,6 +96,7 @@ export const setupTestServer = ({ httpServer: app.listen(0), authAgentFor: (user: User) => createAgent(app, { auth: true, user }), authlessAgent: createAgent(app), + restlessAgent: createAgent(app, { auth: false, noRest: true }), publicApiAgentFor: (user) => publicApiAgent(app, { user }), license: new LicenseMocker(), }; @@ -119,6 +127,13 @@ export const setupTestServer = ({ app.use(...apiRouters); } + if (endpointGroups?.includes('health')) { + app.get('/healthz/readiness', async (_req, res) => { + testDb.isReady() + ? res.status(200).send({ status: 'ok' }) + : res.status(503).send({ status: 'error' }); + }); + } if (endpointGroups.length) { for (const group of endpointGroups) { switch (group) {