diff --git a/charts/platform-server/values.yaml b/charts/platform-server/values.yaml index 197dbd5a7..aed942c28 100644 --- a/charts/platform-server/values.yaml +++ b/charts/platform-server/values.yaml @@ -104,7 +104,7 @@ lifecycle: {} livenessProbe: enabled: true httpGet: - path: /healthz + path: /health port: http initialDelaySeconds: 10 periodSeconds: 10 @@ -113,9 +113,9 @@ livenessProbe: failureThreshold: 6 readinessProbe: - enabled: true + enabled: false httpGet: - path: /readyz + path: /health port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/packages/platform-server/__tests__/infra/health/health.controller.test.ts b/packages/platform-server/__tests__/infra/health/health.controller.test.ts new file mode 100644 index 000000000..b2cdac877 --- /dev/null +++ b/packages/platform-server/__tests__/infra/health/health.controller.test.ts @@ -0,0 +1,131 @@ +import { FastifyAdapter } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; + +import { HealthController } from '../../../src/infra/health/health.controller'; +import { + DockerRunnerStatusService, + DockerRunnerStatusSnapshot, +} from '../../../src/infra/container/dockerRunnerStatus.service'; + +type DockerRunnerStub = { + getSnapshot: () => DockerRunnerStatusSnapshot; +}; + +const createApp = async (service?: DockerRunnerStub) => { + const moduleRef = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: DockerRunnerStatusService, + useFactory: () => service as unknown as DockerRunnerStatusService | undefined, + }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(new FastifyAdapter()); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + return app; +}; + +describe('HealthController', () => { + it('returns docker runner snapshot when dependency is present', async () => { + const snapshot: DockerRunnerStatusSnapshot = { + status: 'up', + optional: false, + endpoint: 'grpc://runner:50051', + lastCheckedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + consecutiveFailures: 0, + }; + + const app = await createApp({ + getSnapshot: () => snapshot, + }); + + try { + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'GET', + url: '/health', + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + + const payload = JSON.parse(response.payload) as { + status: string; + timestamp: string; + checks: { dockerRunner: DockerRunnerStatusSnapshot }; + }; + + expect(payload.status).toBe('ok'); + expect(new Date(payload.timestamp).toString()).not.toBe('Invalid Date'); + expect(payload.checks.dockerRunner).toEqual(snapshot); + } finally { + await app.close(); + } + }); + + it('marks docker runner check as skipped when dependency is missing', async () => { + const app = await createApp(); + + try { + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'GET', + url: '/health', + }); + + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.payload) as { + checks: { + dockerRunner: { + status: string; + optional: boolean; + error?: { name?: string; message: string }; + }; + }; + }; + + expect(payload.checks.dockerRunner.status).toBe('skipped'); + expect(payload.checks.dockerRunner.optional).toBe(true); + expect(payload.checks.dockerRunner.error?.message).toContain('not registered'); + } finally { + await app.close(); + } + }); + + it('degrades docker runner check when dependency throws', async () => { + const app = await createApp({ + getSnapshot: () => { + throw new Error('boom'); + }, + }); + + try { + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'GET', + url: '/health', + }); + + expect(response.statusCode).toBe(200); + + const payload = JSON.parse(response.payload) as { + checks: { + dockerRunner: { + status: string; + optional: boolean; + error?: { name?: string; message: string }; + }; + }; + }; + + expect(payload.checks.dockerRunner.status).toBe('error'); + expect(payload.checks.dockerRunner.optional).toBe(true); + expect(payload.checks.dockerRunner.error?.message).toBe('boom'); + } finally { + await app.close(); + } + }); +}); diff --git a/packages/platform-server/src/infra/health/health.controller.ts b/packages/platform-server/src/infra/health/health.controller.ts index 3059e0d1e..1861f0c17 100644 --- a/packages/platform-server/src/infra/health/health.controller.ts +++ b/packages/platform-server/src/infra/health/health.controller.ts @@ -1,18 +1,76 @@ -import { Controller, Get } from '@nestjs/common'; -import { DockerRunnerStatusService } from '../container/dockerRunnerStatus.service'; +import { Controller, Get, Header, Inject, Optional } from '@nestjs/common'; +import { DockerRunnerStatusService, DockerRunnerStatusSnapshot } from '../container/dockerRunnerStatus.service'; + +type DegradedDockerRunnerCheck = + | { + status: 'skipped'; + optional: true; + error?: { name?: string; message: string }; + } + | { + status: 'error'; + optional: true; + error: { name?: string; message: string }; + }; + +type DockerRunnerCheck = DockerRunnerStatusSnapshot | DegradedDockerRunnerCheck; @Controller() export class HealthController { - constructor(private readonly dockerRunnerStatus: DockerRunnerStatusService) {} + constructor( + @Optional() + @Inject(DockerRunnerStatusService) + private readonly dockerRunnerStatus?: DockerRunnerStatusService, + ) {} @Get('health') - getHealth() { + @Header('Cache-Control', 'no-store') + getHealth(): { status: 'ok'; timestamp: string; checks: { dockerRunner: DockerRunnerCheck } } { + const dockerRunner = this.resolveDockerRunnerCheck(); + return { status: 'ok', timestamp: new Date().toISOString(), - dependencies: { - dockerRunner: this.dockerRunnerStatus.getSnapshot(), + checks: { + dockerRunner, }, }; } + + private resolveDockerRunnerCheck(): DockerRunnerCheck { + if (!this.dockerRunnerStatus) { + return { + status: 'skipped', + optional: true, + error: { + name: 'DependencyUnavailable', + message: 'DockerRunnerStatusService not registered', + }, + }; + } + + try { + return this.dockerRunnerStatus.getSnapshot(); + } catch (error) { + return { + status: 'error', + optional: true, + error: HealthController.serializeError(error), + }; + } + } + + private static serializeError(error: unknown): { name?: string; message: string } { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + }; + } + + return { + name: 'Error', + message: typeof error === 'string' ? error : String(error), + }; + } }