From b0519002f8e11b52a78404bcf44022793c2e4bba Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:47:11 +0200 Subject: [PATCH 01/16] refactor(service-monitor): use DeepMockProxy instead of ReturnType aliases Signed-off-by: William Phetsinorath Change-Id: If8a72fc86040c89440538abed8b4a0ce6a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/package.json | 7 - apps/server-nestjs/src/main.module.ts | 2 + .../registry/registry-health.service.spec.ts | 78 ++++++++ .../registry/registry-health.service.ts | 20 +- .../src/modules/registry/registry.utils.ts | 41 +++- .../service-monitor.constants.ts | 7 + .../service-monitor.controller.spec.ts | 58 ++++++ .../service-monitor.controller.ts | 30 +++ .../service-monitor/service-monitor.module.ts | 30 +++ .../service-monitor.service.spec.ts | 183 ++++++++++++++++++ .../service-monitor.service.ts | 125 ++++++++++++ .../service-monitor/service-monitor.utils.ts | 35 ++++ .../sonarqube-health.service.spec.ts | 69 +++++++ .../sonarqube/sonarqube-health.service.ts | 42 +++- .../modules/sonarqube/sonarqube.constants.ts | 11 ++ pnpm-lock.yaml | 21 -- 16 files changed, 720 insertions(+), 39 deletions(-) create mode 100644 apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.constants.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts create mode 100644 apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts create mode 100644 apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 99df988602..784c03a1ac 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -29,16 +29,9 @@ "test:debug": "vitest --inspect" }, "dependencies": { - "@cpn-console/argocd-plugin": "workspace:^", - "@cpn-console/gitlab-plugin": "workspace:^", - "@cpn-console/harbor-plugin": "workspace:^", "@cpn-console/hooks": "workspace:^", - "@cpn-console/keycloak-plugin": "workspace:^", "@cpn-console/logger": "workspace:^", - "@cpn-console/nexus-plugin": "workspace:^", "@cpn-console/shared": "workspace:^", - "@cpn-console/sonarqube-plugin": "workspace:^", - "@cpn-console/vault-plugin": "workspace:^", "@gitbeaker/core": "catalog:runtime", "@gitbeaker/requester-utils": "catalog:runtime", "@gitbeaker/rest": "catalog:runtime", diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0fdc0dce02..ffe46e2440 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -21,6 +21,7 @@ import { ProjectSecretsModule } from './modules/project-secrets/project-secrets. import { ProjectServicesModule } from './modules/project-services/project-services.module' import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' +import { ServiceMonitorModule } from './modules/service-monitor/service-monitor.module' import { StageModule } from './modules/stage/stage.module' import { SystemConfigModule } from './modules/system-config/system-config.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' @@ -55,6 +56,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectSecretsModule, ProjectServicesModule, RepositoryModule, + ServiceMonitorModule, ScheduleModule.forRoot(), StageModule, SystemConfigModule, diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts new file mode 100644 index 0000000000..814c961c4e --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts @@ -0,0 +1,78 @@ +import type { HarborConfig } from '../../config/harbor.config' +import { MonitorStatus } from '@cpn-console/shared' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { harborConfigFactory } from '../../config/harbor.config' +import { RegistryHealthService } from './registry-health.service' + +const harborUrl = 'https://harbor.example' +const healthUrl = `${harborUrl}/api/v2.0/health` + +const server = setupServer() + +describe('registryHealthService', () => { + let service: RegistryHealthService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const config = mockDeep({ + url: harborUrl, + internalUrl: undefined, + admin: 'admin', + adminPassword: 'password', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryHealthService, + { provide: harborConfigFactory.KEY, useValue: config }, + { provide: HealthIndicatorService, useValue: mockDeep() }, + ], + }).compile() + + service = module.get(RegistryHealthService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('reports OK when every component is healthy', async () => { + server.use(http.get(healthUrl, () => HttpResponse.json({ status: 'healthy', components: [] }))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + }) + + it('reports ERROR when a core component is unhealthy', async () => { + server.use(http.get(healthUrl, () => HttpResponse.json({ + status: 'unhealthy', + components: [{ name: 'registryctl', status: 'unhealthy' }], + }))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.ERROR, message: 'Service en erreur' }) + }) + + it('reports WARNING when only a non-core component is unhealthy', async () => { + server.use(http.get(healthUrl, () => HttpResponse.json({ + status: 'unhealthy', + components: [{ name: 'jobservice', status: 'unhealthy' }], + }))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.WARNING, message: 'Service dégradé' }) + }) + + it('reports ERROR when the health endpoint does not answer with a 200', async () => { + server.use(http.get(healthUrl, () => new HttpResponse(null, { status: 503 }))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.ERROR, message: 'Fatal Error' }) + }) + + it('reports UNKNOW with the cause when the request fails', async () => { + server.use(http.get(healthUrl, () => HttpResponse.error())) + + await expect(service.monitor()).resolves.toMatchObject({ status: MonitorStatus.UNKNOW, cause: expect.any(Error) }) + }) +}) \ No newline at end of file diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index ef29547a6f..28f3e25a77 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -3,6 +3,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { harborConfigFactory } from '../../config/harbor.config' import { PLUGIN_NAME } from './registry.constants' +import { monitorHarborHealth } from './registry.utils' @Injectable() export class RegistryHealthService { @@ -11,19 +12,28 @@ export class RegistryHealthService { @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} + // Legacy harbor monitor reads /health, not /ping, to split degraded from failed. + monitor() { + return monitorHarborHealth(() => { + const url = new URL('/api/v2.0/health', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() + return fetch(url, { method: 'GET', headers: this.authHeaders() }) + }) + } + async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - const headers: Record = {} - const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` - const base64 = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${base64}` try { const url = new URL('/api/v2.0/ping', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() - const response = await fetch(url, { method: 'GET', headers }) + const response = await fetch(url, { method: 'GET', headers: this.authHeaders() }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) } } + + private authHeaders(): Record { + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + return { Authorization: `Basic ${Buffer.from(credentials).toString('base64')}` } + } } diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index 778869ef13..f03333607b 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,6 +1,6 @@ import type { ProjectWithDetails } from './registry-datastore.service' import type { RegistryResponse } from './registry-http-client.service' -import { removeTrailingSlash } from '@cpn-console/shared' +import { MonitorStatus, removeTrailingSlash } from '@cpn-console/shared' import { HttpStatus } from '@nestjs/common' import z from 'zod' @@ -124,3 +124,42 @@ function parseUnit(unit: string) { return 0 } } + +export interface RegistryProbeOutcome { + status: MonitorStatus + message: string + cause?: unknown +} + +// Legacy harbor monitor reads /health, not /ping, to split degraded from failed. +const MONITOR_ERROR_MESSAGE = 'Erreur lors la requête' + +const CORE_COMPONENTS = ['core', 'database', 'portal', 'registry', 'registryctl'] + +const harborHealthBodySchema = z.object({ + status: z.string(), + components: z.array(z.object({ + name: z.string(), + status: z.string(), + })).optional(), +}) + +export async function monitorHarborHealth(fetchHealth: () => Promise): Promise { + try { + const response = await fetchHealth() + if (response.status !== HttpStatus.OK) return { status: MonitorStatus.ERROR, message: 'Fatal Error' } + + const parsed = harborHealthBodySchema.safeParse(await response.json()) + if (!parsed.success) return { status: MonitorStatus.UNKNOW, message: MONITOR_ERROR_MESSAGE } + if (parsed.data.status === 'healthy') return { status: MonitorStatus.OK, message: MonitorStatus.OK } + + const failedCoreComponent = parsed.data.components?.some(component => + component.status === 'unhealthy' + && CORE_COMPONENTS.includes(component.name), + ) + if (failedCoreComponent) return { status: MonitorStatus.ERROR, message: 'Service en erreur' } + return { status: MonitorStatus.WARNING, message: 'Service dégradé' } + } catch (error) { + return { status: MonitorStatus.UNKNOW, message: MONITOR_ERROR_MESSAGE, cause: error } + } +} diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.constants.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.constants.ts new file mode 100644 index 0000000000..371e97e7b8 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.constants.ts @@ -0,0 +1,7 @@ +export const INTERVAL_MS = 5 * 60 * 1000 + +export const ERROR_MESSAGE = 'Service en erreur' + +export const REQUEST_ERROR_MESSAGE = 'Erreur lors la requête' + +export const PENDING_MESSAGE = 'En attente d\'une première vérification' diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts new file mode 100644 index 0000000000..8edfe39f63 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts @@ -0,0 +1,58 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceMonitorController } from './service-monitor.controller' +import { ServiceMonitorService } from './service-monitor.service' + +describe('serviceMonitorController', () => { + let module: TestingModule + let controller: ServiceMonitorController + let service: MockProxy + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceMonitorController], + providers: [ + { provide: ServiceMonitorService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceMonitorController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('delegates health-services to the service', async () => { + const health = [{ name: 'keycloak', status: 'up' }] + service.getServiceHealth.mockResolvedValue(health) + + expect(await controller.getServiceHealth()).toBe(health) + expect(service.getServiceHealth).toHaveBeenCalledTimes(1) + }) + + it('delegates complete-services to the service', async () => { + const health = [{ name: 'keycloak', status: 'up' }] + service.getCompleteServiceHealth.mockResolvedValue(health) + + expect(await controller.getCompleteServiceHealth()).toBe(health) + expect(service.getCompleteServiceHealth).toHaveBeenCalledTimes(1) + }) + + it('delegates refresh-services to the service', async () => { + const health = [{ name: 'keycloak', status: 'up' }] + service.refreshServiceHealth.mockResolvedValue(health) + + expect(await controller.refreshServiceHealth()).toBe(health) + expect(service.refreshServiceHealth).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts new file mode 100644 index 0000000000..f042fd17fa --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceMonitorService } from './service-monitor.service' + +@Controller('api/v1') +export class ServiceMonitorController { + constructor( + @Inject(ServiceMonitorService) private readonly service: ServiceMonitorService, + ) {} + + @Get('health-services') + getServiceHealth() { + return this.service.getServiceHealth() + } + + @Get('complete-services') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + getCompleteServiceHealth() { + return this.service.getCompleteServiceHealth() + } + + @Get('refresh-services') + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + refreshServiceHealth() { + return this.service.refreshServiceHealth() + } +} diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts new file mode 100644 index 0000000000..913711042e --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { ServiceMonitorController } from './service-monitor.controller' +import { ServiceMonitorService } from './service-monitor.service' + +@Module({ + imports: [ + AuthModule, + UserPermissionModule, + KeycloakModule, + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_HARBOR'), + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], + controllers: [ServiceMonitorController], + providers: [ServiceMonitorService], +}) +export class ServiceMonitorModule {} diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts new file mode 100644 index 0000000000..faad9ca7ce --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -0,0 +1,183 @@ +import type { Mock } from 'vitest' +import { MonitorStatus } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { INTERVAL_MS } from './service-monitor.constants' +import { ServiceMonitorService } from './service-monitor.service' + +describe('serviceMonitorService', () => { + describe('with every optional probe registered', () => { + let keycloak: DeepMockProxy + let argocd: DeepMockProxy + let gitlab: DeepMockProxy + let harbor: DeepMockProxy + let nexus: DeepMockProxy + let sonarqube: DeepMockProxy + let vault: DeepMockProxy + let service: ServiceMonitorService + + beforeEach(async () => { + keycloak = mockDeep() + argocd = mockDeep() + gitlab = mockDeep() + harbor = mockDeep() + nexus = mockDeep() + sonarqube = mockDeep() + vault = mockDeep() + keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) + argocd.check.mockResolvedValue({ argocd: { status: 'up', httpStatus: 200 } }) + gitlab.check.mockResolvedValue({ gitlab: { status: 'up', httpStatus: 200 } }) + harbor.monitor.mockResolvedValue({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + nexus.check.mockResolvedValue({ nexus: { status: 'up', httpStatus: 200 } }) + sonarqube.monitor.mockResolvedValue({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + vault.check.mockResolvedValue({ vault: { status: 'up', httpStatus: 200 } }) + const moduleRef = await Test.createTestingModule({ + providers: [ + ServiceMonitorService, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: RegistryHealthService, useValue: harbor }, + { provide: NexusHealthService, useValue: nexus }, + { provide: SonarqubeHealthService, useValue: sonarqube }, + { provide: VaultHealthService, useValue: vault }, + ], + }).compile() + service = moduleRef.get(ServiceMonitorService) + }) + + afterEach(() => service.onModuleDestroy()) + + it('aggregates every injected probe', async () => { + keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 500 } }) + + const health = await service.refreshServiceHealth() + + expect(health).toEqual([ + { name: 'ArgoCD', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + { name: 'Gitlab', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + { name: 'Harbor', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Service en erreur' }, + { name: 'Nexus', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + { name: 'SonarQube', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + { name: 'Vault', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, + ]) + }) + + it('passes the SonarQube and Harbor monitor verdicts through', async () => { + sonarqube.monitor.mockResolvedValue({ status: MonitorStatus.WARNING, message: 'Service dégradé' }) + harbor.monitor.mockResolvedValue({ status: MonitorStatus.ERROR, message: 'Service en erreur' }) + + const health = await service.refreshServiceHealth() + + expect(health).toContainEqual(expect.objectContaining({ name: 'SonarQube', status: MonitorStatus.WARNING, message: 'Service dégradé' })) + expect(health).toContainEqual(expect.objectContaining({ name: 'Harbor', status: MonitorStatus.ERROR, message: 'Service en erreur' })) + }) + + it('serves the last known state instead of re-probing on every read', async () => { + await service.refreshServiceHealth() + keycloak.check.mockClear() + + service.getServiceHealth() + service.getServiceHealth() + + expect(keycloak.check).not.toHaveBeenCalled() + }) + + it('exposes the cause on the admin route, never on the public one', async () => { + keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 503, message: 'connection refused' } }) + await service.refreshServiceHealth() + + expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'connection refused' })) + expect(service.getServiceHealth().every(entry => !('cause' in entry))).toBe(true) + }) + + it('refreshServiceHealth re-probes every enabled service', async () => { + await service.refreshServiceHealth() + await service.refreshServiceHealth() + + expect(keycloak.check).toHaveBeenCalledTimes(2) + expect(sonarqube.monitor).toHaveBeenCalledTimes(2) + expect(harbor.monitor).toHaveBeenCalledTimes(2) + }) + + it('probes once and schedules the periodic refresh on init', async () => { + service.onModuleInit() + await new Promise(resolve => setImmediate(resolve)) + + expect(keycloak.check).toHaveBeenCalledTimes(1) + }) + }) + + describe('without optional probes registered', () => { + let keycloak: { check: Mock<() => Promise> } + let service: ServiceMonitorService + + beforeEach(async () => { + keycloak = { check: vi.fn<() => Promise>() } + const moduleRef = await Test.createTestingModule({ + providers: [ + ServiceMonitorService, + { provide: KeycloakHealthService, useValue: keycloak }, + ], + }).compile() + service = moduleRef.get(ServiceMonitorService) + }) + + afterEach(() => service.onModuleDestroy()) + + it('skips unregistered probes', async () => { + keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) + + const health = await service.refreshServiceHealth() + + expect(health.map(entry => entry.name)).toEqual(['Keycloak']) + }) + + it('serves the pending state until the first probe', () => { + expect(service.getServiceHealth()).toEqual([ + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'En attente d\'une première vérification' }, + ]) + }) + + it('reports UNKNOW with the request error when the probe rejects', async () => { + keycloak.check.mockRejectedValue(new Error('boom')) + + const health = await service.refreshServiceHealth() + + expect(health).toEqual([ + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête' }, + ]) + expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: expect.any(Error) })) + }) + + it('reports UNKNOW when the probe payload has no status field', async () => { + keycloak.check.mockResolvedValue({ keycloak: {} }) + + const health = await service.refreshServiceHealth() + + expect(health).toEqual([ + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Service en erreur' }, + ]) + }) + + it('reports ERROR with the probe detail message when status is not up', async () => { + keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 503, message: 'connection refused' } }) + + const health = await service.refreshServiceHealth() + + expect(health).toEqual([ + { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'connection refused' }, + ]) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts new file mode 100644 index 0000000000..1fd02112e4 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -0,0 +1,125 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { MonitorStatus } from '@cpn-console/shared' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { INTERVAL_MS, PENDING_MESSAGE, REQUEST_ERROR_MESSAGE } from './service-monitor.constants' +import { fromHealthCheck } from './service-monitor.utils' +import type { ProbeOutcome } from './service-monitor.utils' + +export type ServiceHealth = MonitorInfos & { name: string } + +interface MonitoredService { + name: string + probe: () => Promise + lastStatus: MonitorInfos +} + +@Injectable() +export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { + private readonly interval: number = INTERVAL_MS + private readonly services: MonitoredService[] + private timer: NodeJS.Timeout | undefined + + constructor( + @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(RegistryHealthService) @Optional() private readonly harbor?: RegistryHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(SonarqubeHealthService) @Optional() private readonly sonarqube?: SonarqubeHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + ) { + const { + argocd: argocdHealth, + gitlab: gitlabHealth, + harbor: harborHealth, + nexus: nexusHealth, + sonarqube: sonarqubeHealth, + vault: vaultHealth, + } = this + const probes: Array<[string, (() => Promise) | undefined]> = [ + ['ArgoCD', argocdHealth && (() => argocdHealth.check().then(fromHealthCheck))], + ['Gitlab', gitlabHealth && (() => gitlabHealth.check().then(fromHealthCheck))], + ['Harbor', harborHealth && (() => harborHealth.monitor())], + ['Keycloak', () => this.keycloak.check().then(fromHealthCheck)], + ['Nexus', nexusHealth && (() => nexusHealth.check().then(fromHealthCheck))], + ['SonarQube', sonarqubeHealth && (() => sonarqubeHealth.monitor())], + ['Vault', vaultHealth && (() => vaultHealth.check().then(fromHealthCheck))], + ] + + this.services = probes.flatMap(([name, probe]) => probe ? [this.track(name, probe)] : []) + } + + getServiceHealth(): ServiceHealth[] { + return this.snapshot(false) + } + + getCompleteServiceHealth(): ServiceHealth[] { + return this.snapshot(true) + } + + async refreshServiceHealth(): Promise { + await this.runAll() + return this.snapshot(false) + } + + onModuleInit(): void { + void this.refresh() + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer) + } + + // Legacy Monitor.refresh(): serve the last known state between periodic passes. + private async refresh(): Promise { + if (this.timer) clearInterval(this.timer) + this.timer = setInterval(() => void this.runAll(), this.interval) + await this.runAll() + } + + private async runAll(): Promise { + await Promise.all(this.services.map(async (service) => { + try { + const outcome = await service.probe() + service.lastStatus = { ...outcome, interval: this.interval, lastUpdateTimestamp: Date.now() } + } catch (error) { + service.lastStatus = { + interval: this.interval, + lastUpdateTimestamp: Date.now(), + status: MonitorStatus.UNKNOW, + message: REQUEST_ERROR_MESSAGE, + cause: error, + } + } + })) + } + + private snapshot(withCause: boolean): ServiceHealth[] { + return this.services.map(({ name, lastStatus }) => { + const { cause, ...health } = lastStatus + return { name, ...health, ...(withCause && cause !== undefined ? { cause } : {}) } + }) + } + + private track(name: string, probe: () => Promise): MonitoredService { + return { + name, + probe, + lastStatus: { + interval: this.interval, + lastUpdateTimestamp: Date.now(), + status: MonitorStatus.UNKNOW, + message: PENDING_MESSAGE, + cause: 'App just started', + }, + } + } +} diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts new file mode 100644 index 0000000000..c389e1884c --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -0,0 +1,35 @@ +import { MonitorStatus } from '@cpn-console/shared' +import { ERROR_MESSAGE } from './service-monitor.constants' + +export interface ProbeOutcome { + status: MonitorStatus + message: string + cause?: unknown +} + +interface ProbeDetail { + status: string + message?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isProbeDetail(detail: unknown): detail is ProbeDetail { + return isRecord(detail) && 'status' in detail && typeof detail.status === 'string' +} + +export function fromHealthCheck(result: unknown): ProbeOutcome { + const detail = isRecord(result) ? result[Object.keys(result)[0]] : undefined + if (!isProbeDetail(detail)) { + return { status: MonitorStatus.UNKNOW, message: ERROR_MESSAGE } + } + + const up = detail.status === 'up' + return { + status: up ? MonitorStatus.OK : MonitorStatus.ERROR, + message: up ? MonitorStatus.OK : detail.message ?? ERROR_MESSAGE, + ...(up ? {} : { cause: detail.message ?? ERROR_MESSAGE }), + } +} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts new file mode 100644 index 0000000000..eb3b50de14 --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts @@ -0,0 +1,69 @@ +import type { ConfigType } from '@nestjs/config' +import { MonitorStatus } from '@cpn-console/shared' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { SonarqubeHealthService } from './sonarqube-health.service' + +const sonarUrl = 'https://sonarqube.internal' +const healthUrl = `${sonarUrl}/api/system/health` + +const server = setupServer() + +describe('sonarqubeHealthService', () => { + let service: SonarqubeHealthService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const config = mockDeep>({ + apiToken: 'my-token', + url: sonarUrl, + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeHealthService, + { provide: sonarqubeConfigFactory.KEY, useValue: config }, + { provide: HealthIndicatorService, useValue: mockDeep() }, + ], + }).compile() + + service = module.get(SonarqubeHealthService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it.each([ + ['GREEN', MonitorStatus.OK, MonitorStatus.OK], + ['YELLOW', MonitorStatus.WARNING, 'Service dégradé'], + ['RED', MonitorStatus.ERROR, 'Service en panne'], + ])('maps SonarQube health %s to %s', async (health, status, message) => { + server.use(http.get(healthUrl, () => HttpResponse.json({ health, causes: [] }))) + + await expect(service.monitor()).resolves.toEqual({ status, message }) + }) + + it('reports UNKNOW when the API answers without a health field', async () => { + server.use(http.get(healthUrl, () => HttpResponse.json({}))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.UNKNOW, message: 'Erreur lors la requête' }) + }) + + it('reports UNKNOW when the API answers with a non-200 status', async () => { + server.use(http.get(healthUrl, () => new HttpResponse(null, { status: 503 }))) + + await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.UNKNOW, message: 'Erreur lors la requête' }) + }) + + it('reports UNKNOW with the cause when the request fails', async () => { + server.use(http.get(healthUrl, () => HttpResponse.error())) + + await expect(service.monitor()).resolves.toMatchObject({ status: MonitorStatus.UNKNOW, cause: expect.any(Error) }) + }) +}) \ No newline at end of file diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 388e0452a9..281590ccde 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,7 +1,19 @@ import type { ConfigType } from '@nestjs/config' +import { MonitorStatus } from '@cpn-console/shared' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { REQUEST_ERROR_MESSAGE, SONARQUBE_HEALTH_STATUS } from './sonarqube.constants' + +export interface SonarqubeProbeOutcome { + status: MonitorStatus + message: string + cause?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} @Injectable() export class SonarqubeHealthService { @@ -10,18 +22,38 @@ export class SonarqubeHealthService { @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} + // Legacy sonarqube monitor maps /system/health, not the bare 200, to the verdict. + async monitor(): Promise { + try { + const response = await fetch(this.healthUrl(), { headers: this.authHeaders() }) + if (response.status !== HttpStatus.OK) return { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE } + + const data: unknown = await response.json() + const health = isRecord(data) && typeof data.health === 'string' ? data.health : undefined + const mapped = health ? SONARQUBE_HEALTH_STATUS[health] : undefined + return mapped ?? { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE } + } catch (error) { + return { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE, cause: error } + } + } + async check(key: string) { const indicator = this.healthIndicator.check(key) - const headers: Record = {} - const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') - headers.Authorization = `Bearer ${bearerToken}` try { - const url = new URL('/api/system/health', this.sonarqubeConfig.internalUrl ?? this.sonarqubeConfig.url).toString() - const response = await fetch(url, { headers }) + const response = await fetch(this.healthUrl(), { headers: this.authHeaders() }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { return indicator.down(error instanceof Error ? error.message : String(error)) } } + + private healthUrl(): string { + return new URL('/api/system/health', this.sonarqubeConfig.internalUrl ?? this.sonarqubeConfig.url).toString() + } + + private authHeaders(): Record { + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + return { Authorization: `Bearer ${bearerToken}` } + } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index 0935f737c6..4ca10b8670 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -1,3 +1,5 @@ +import { MonitorStatus } from '@cpn-console/shared' + export const PLUGIN_NAME = 'sonarqube' export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' @@ -54,3 +56,12 @@ export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' + +// Health monitor messages +export const REQUEST_ERROR_MESSAGE = 'Erreur lors la requête' + +export const SONARQUBE_HEALTH_STATUS: Record = { + GREEN: { status: MonitorStatus.OK, message: MonitorStatus.OK }, + YELLOW: { status: MonitorStatus.WARNING, message: 'Service dégradé' }, + RED: { status: MonitorStatus.ERROR, message: 'Service en panne' }, +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8dc59f157..6573cc265d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,36 +761,15 @@ importers: apps/server-nestjs: dependencies: - '@cpn-console/argocd-plugin': - specifier: workspace:^ - version: link:../../plugins/argocd - '@cpn-console/gitlab-plugin': - specifier: workspace:^ - version: link:../../plugins/gitlab - '@cpn-console/harbor-plugin': - specifier: workspace:^ - version: link:../../plugins/harbor '@cpn-console/hooks': specifier: workspace:^ version: link:../../packages/hooks - '@cpn-console/keycloak-plugin': - specifier: workspace:^ - version: link:../../plugins/keycloak '@cpn-console/logger': specifier: workspace:^ version: link:../../packages/logger - '@cpn-console/nexus-plugin': - specifier: workspace:^ - version: link:../../plugins/nexus '@cpn-console/shared': specifier: workspace:^ version: link:../../packages/shared - '@cpn-console/sonarqube-plugin': - specifier: workspace:^ - version: link:../../plugins/sonarqube - '@cpn-console/vault-plugin': - specifier: workspace:^ - version: link:../../plugins/vault '@gitbeaker/core': specifier: catalog:runtime version: 40.6.0 From b0ab76e7bfadb8ad836014805bfaf51c09ae9040 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:41:05 +0200 Subject: [PATCH 02/16] refactor(service-monitor): type the cause as a string in the contract Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/service-monitor/service-monitor.service.spec.ts | 2 +- .../src/modules/service-monitor/service-monitor.service.ts | 4 ++-- .../src/modules/service-monitor/service-monitor.utils.ts | 4 ++++ packages/shared/src/contracts/service-monitor.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index faad9ca7ce..5379338678 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -157,7 +157,7 @@ describe('serviceMonitorService', () => { expect(health).toEqual([ { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête' }, ]) - expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: expect.any(Error) })) + expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) }) it('reports UNKNOW when the probe payload has no status field', async () => { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index 1fd02112e4..ec987d700a 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -10,7 +10,7 @@ import { RegistryHealthService } from '../registry/registry-health.service' import { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' import { VaultHealthService } from '../vault/vault-health.service' import { INTERVAL_MS, PENDING_MESSAGE, REQUEST_ERROR_MESSAGE } from './service-monitor.constants' -import { fromHealthCheck } from './service-monitor.utils' +import { fromHealthCheck, toCause } from './service-monitor.utils' import type { ProbeOutcome } from './service-monitor.utils' export type ServiceHealth = MonitorInfos & { name: string } @@ -105,7 +105,7 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { private snapshot(withCause: boolean): ServiceHealth[] { return this.services.map(({ name, lastStatus }) => { const { cause, ...health } = lastStatus - return { name, ...health, ...(withCause && cause !== undefined ? { cause } : {}) } + return { name, ...health, ...(withCause && cause !== undefined ? { cause: toCause(cause) } : {}) } }) } diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts index c389e1884c..82ffabb2c6 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -33,3 +33,7 @@ export function fromHealthCheck(result: unknown): ProbeOutcome { ...(up ? {} : { cause: detail.message ?? ERROR_MESSAGE }), } } + +export function toCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause) +} diff --git a/packages/shared/src/contracts/service-monitor.ts b/packages/shared/src/contracts/service-monitor.ts index c2b02e304b..6ec2d10b7a 100644 --- a/packages/shared/src/contracts/service-monitor.ts +++ b/packages/shared/src/contracts/service-monitor.ts @@ -23,7 +23,7 @@ export const serviceContract = contractInstance.router({ description: 'Retrieve services health with cause.', responses: { 200: ServiceHealthSchema.extend({ - cause: z.any().optional(), + cause: z.string().optional(), }).array(), 401: ErrorSchema, 403: ErrorSchema, From e50548e97b0780faffa907b1ab0d28695cc4b41b Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:37:43 +0200 Subject: [PATCH 03/16] refactor(service-monitor): use a Set for core components and drop non-promise awaits Signed-off-by: William Phetsinorath Change-Id: I335fba92cfd5882a8a05c51ef59d26696a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/modules/registry/registry.utils.ts | 4 ++-- .../service-monitor/service-monitor.controller.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index f03333607b..c1f2b5c61a 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -134,7 +134,7 @@ export interface RegistryProbeOutcome { // Legacy harbor monitor reads /health, not /ping, to split degraded from failed. const MONITOR_ERROR_MESSAGE = 'Erreur lors la requête' -const CORE_COMPONENTS = ['core', 'database', 'portal', 'registry', 'registryctl'] +const CORE_COMPONENTS = new Set(['core', 'database', 'portal', 'registry', 'registryctl']) const harborHealthBodySchema = z.object({ status: z.string(), @@ -155,7 +155,7 @@ export async function monitorHarborHealth(fetchHealth: () => Promise): const failedCoreComponent = parsed.data.components?.some(component => component.status === 'unhealthy' - && CORE_COMPONENTS.includes(component.name), + && CORE_COMPONENTS.has(component.name), ) if (failedCoreComponent) return { status: MonitorStatus.ERROR, message: 'Service en erreur' } return { status: MonitorStatus.WARNING, message: 'Service dégradé' } diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts index 8edfe39f63..d8d48b98eb 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts @@ -36,7 +36,7 @@ describe('serviceMonitorController', () => { const health = [{ name: 'keycloak', status: 'up' }] service.getServiceHealth.mockResolvedValue(health) - expect(await controller.getServiceHealth()).toBe(health) + await expect(controller.getServiceHealth()).resolves.toBe(health) expect(service.getServiceHealth).toHaveBeenCalledTimes(1) }) @@ -44,7 +44,7 @@ describe('serviceMonitorController', () => { const health = [{ name: 'keycloak', status: 'up' }] service.getCompleteServiceHealth.mockResolvedValue(health) - expect(await controller.getCompleteServiceHealth()).toBe(health) + await expect(controller.getCompleteServiceHealth()).resolves.toBe(health) expect(service.getCompleteServiceHealth).toHaveBeenCalledTimes(1) }) From c03fb45c659450a03cbbe13b8978ad4bfc35506e Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:16:25 +0200 Subject: [PATCH 04/16] refactor(service-monitor): pass the indicator key explicitly to fromHealthCheck Signed-off-by: William Phetsinorath Change-Id: Iaecd853ed363b4bfd770aa5b447aec656a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/service-monitor/service-monitor.service.ts | 10 +++++----- .../modules/service-monitor/service-monitor.utils.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index ec987d700a..c07542ef28 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -45,13 +45,13 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { vault: vaultHealth, } = this const probes: Array<[string, (() => Promise) | undefined]> = [ - ['ArgoCD', argocdHealth && (() => argocdHealth.check().then(fromHealthCheck))], - ['Gitlab', gitlabHealth && (() => gitlabHealth.check().then(fromHealthCheck))], + ['ArgoCD', argocdHealth && (() => argocdHealth.check().then(result => fromHealthCheck(result, 'argocd')))], + ['Gitlab', gitlabHealth && (() => gitlabHealth.check().then(result => fromHealthCheck(result, 'gitlab')))], ['Harbor', harborHealth && (() => harborHealth.monitor())], - ['Keycloak', () => this.keycloak.check().then(fromHealthCheck)], - ['Nexus', nexusHealth && (() => nexusHealth.check().then(fromHealthCheck))], + ['Keycloak', () => this.keycloak.check().then(result => fromHealthCheck(result, 'keycloak'))], + ['Nexus', nexusHealth && (() => nexusHealth.check().then(result => fromHealthCheck(result, 'nexus')))], ['SonarQube', sonarqubeHealth && (() => sonarqubeHealth.monitor())], - ['Vault', vaultHealth && (() => vaultHealth.check().then(fromHealthCheck))], + ['Vault', vaultHealth && (() => vaultHealth.check().then(result => fromHealthCheck(result, 'vault')))], ] this.services = probes.flatMap(([name, probe]) => probe ? [this.track(name, probe)] : []) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts index 82ffabb2c6..464ddd20f0 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -20,8 +20,8 @@ function isProbeDetail(detail: unknown): detail is ProbeDetail { return isRecord(detail) && 'status' in detail && typeof detail.status === 'string' } -export function fromHealthCheck(result: unknown): ProbeOutcome { - const detail = isRecord(result) ? result[Object.keys(result)[0]] : undefined +export function fromHealthCheck(result: unknown, key: string): ProbeOutcome { + const detail = isRecord(result) ? result[key] : undefined if (!isProbeDetail(detail)) { return { status: MonitorStatus.UNKNOW, message: ERROR_MESSAGE } } From aa671452d634022810f6d1cd542e3183fa9c0ccb Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:11:12 +0200 Subject: [PATCH 05/16] refactor(server-nestjs): share isRecord from utils/record.utils Deduplicate the private isRecord copies in service-monitor.utils and sonarqube-health.service onto src/utils/record.utils, next to hasEntries. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I86a0cc083756a99f4b7f47c8c7ce8a596a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../src/modules/service-monitor/service-monitor.utils.ts | 5 +---- .../src/modules/sonarqube/sonarqube-health.service.ts | 5 +---- apps/server-nestjs/src/utils/record.utils.ts | 4 ++++ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts index 464ddd20f0..f9a8aa9aad 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -1,4 +1,5 @@ import { MonitorStatus } from '@cpn-console/shared' +import { isRecord } from '../../utils/record.utils' import { ERROR_MESSAGE } from './service-monitor.constants' export interface ProbeOutcome { @@ -12,10 +13,6 @@ interface ProbeDetail { message?: string } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function isProbeDetail(detail: unknown): detail is ProbeDetail { return isRecord(detail) && 'status' in detail && typeof detail.status === 'string' } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 281590ccde..2e7aa304ec 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -3,6 +3,7 @@ import { MonitorStatus } from '@cpn-console/shared' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { isRecord } from '../../utils/record.utils' import { REQUEST_ERROR_MESSAGE, SONARQUBE_HEALTH_STATUS } from './sonarqube.constants' export interface SonarqubeProbeOutcome { @@ -11,10 +12,6 @@ export interface SonarqubeProbeOutcome { cause?: unknown } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - @Injectable() export class SonarqubeHealthService { constructor( diff --git a/apps/server-nestjs/src/utils/record.utils.ts b/apps/server-nestjs/src/utils/record.utils.ts index 05418fcf86..0c6d57ec6d 100644 --- a/apps/server-nestjs/src/utils/record.utils.ts +++ b/apps/server-nestjs/src/utils/record.utils.ts @@ -1,3 +1,7 @@ export function hasEntries(record: Record): boolean { return Object.keys(record).length > 0 } + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} From ec0286fdefcc271369a2c99bb68a211df1d260ee Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:21:36 +0200 Subject: [PATCH 06/16] refactor(service-monitor): move the ProbeOutcome type import into the type block Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I6b3e511e25e17db048f94a7cb31bdbb56a6a6964 --- .../src/modules/service-monitor/service-monitor.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index c07542ef28..f15ecb8fc4 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -1,5 +1,6 @@ import type { MonitorInfos } from '@cpn-console/shared' import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import type { ProbeOutcome } from './service-monitor.utils' import { MonitorStatus } from '@cpn-console/shared' import { Inject, Injectable, Optional } from '@nestjs/common' import { ArgoCDHealthService } from '../argocd/argocd-health.service' @@ -11,7 +12,6 @@ import { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' import { VaultHealthService } from '../vault/vault-health.service' import { INTERVAL_MS, PENDING_MESSAGE, REQUEST_ERROR_MESSAGE } from './service-monitor.constants' import { fromHealthCheck, toCause } from './service-monitor.utils' -import type { ProbeOutcome } from './service-monitor.utils' export type ServiceHealth = MonitorInfos & { name: string } From 8c355a5beabb556acdc47df2e2c0a010e7f21f83 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 15:49:03 +0200 Subject: [PATCH 07/16] refactor(service-monitor): fold monitor into check via the terminus session Remove the parallel monitor() probe paths: Harbor and SonarQube expose a single check() that probes the legacy monitor endpoint and reports the verdict directly with indicator.up()/down(), carrying message and cause as indicator data. fromHealthCheck lifts message and cause into the MonitorInfos shape, so every probe aggregates through the same check() path without MonitorStatus leaking into the health services. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I746a21c6837dd2c448f192aec7d763ca6a6a6964 --- .../registry/registry-health.service.spec.ts | 27 ++++++------ .../registry/registry-health.service.ts | 40 ++++++++++++------ .../src/modules/registry/registry.utils.ts | 41 +------------------ .../service-monitor.service.spec.ts | 16 ++++---- .../service-monitor.service.ts | 4 +- .../service-monitor/service-monitor.utils.ts | 12 ++++-- .../sonarqube-health.service.spec.ts | 29 ++++++------- .../sonarqube/sonarqube-health.service.ts | 34 ++++----------- .../modules/sonarqube/sonarqube.constants.ts | 8 ---- 9 files changed, 85 insertions(+), 126 deletions(-) diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts index 814c961c4e..e221dfe1a4 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.spec.ts @@ -1,5 +1,4 @@ import type { HarborConfig } from '../../config/harbor.config' -import { MonitorStatus } from '@cpn-console/shared' import { HealthIndicatorService } from '@nestjs/terminus' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' @@ -31,7 +30,7 @@ describe('registryHealthService', () => { providers: [ RegistryHealthService, { provide: harborConfigFactory.KEY, useValue: config }, - { provide: HealthIndicatorService, useValue: mockDeep() }, + HealthIndicatorService, ], }).compile() @@ -40,39 +39,41 @@ describe('registryHealthService', () => { afterEach(() => server.resetHandlers()) afterAll(() => server.close()) - it('reports OK when every component is healthy', async () => { + it('reports up when every component is healthy', async () => { server.use(http.get(healthUrl, () => HttpResponse.json({ status: 'healthy', components: [] }))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + await expect(service.check()).resolves.toEqual({ harbor: { status: 'up' } }) }) - it('reports ERROR when a core component is unhealthy', async () => { + it('reports down when a core component is unhealthy', async () => { server.use(http.get(healthUrl, () => HttpResponse.json({ status: 'unhealthy', components: [{ name: 'registryctl', status: 'unhealthy' }], }))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.ERROR, message: 'Service en erreur' }) + await expect(service.check()).resolves.toEqual({ harbor: { status: 'down', message: 'Service en erreur' } }) }) - it('reports WARNING when only a non-core component is unhealthy', async () => { + it('reports a degraded up when only a non-core component is unhealthy', async () => { server.use(http.get(healthUrl, () => HttpResponse.json({ status: 'unhealthy', components: [{ name: 'jobservice', status: 'unhealthy' }], }))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.WARNING, message: 'Service dégradé' }) + await expect(service.check()).resolves.toEqual({ harbor: { status: 'up', message: 'Service dégradé' } }) }) - it('reports ERROR when the health endpoint does not answer with a 200', async () => { + it('reports down when the health endpoint does not answer with a 200', async () => { server.use(http.get(healthUrl, () => new HttpResponse(null, { status: 503 }))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.ERROR, message: 'Fatal Error' }) + await expect(service.check()).resolves.toEqual({ harbor: { status: 'down', message: 'Fatal Error' } }) }) - it('reports UNKNOW with the cause when the request fails', async () => { + it('reports down with the cause when the request fails', async () => { server.use(http.get(healthUrl, () => HttpResponse.error())) - await expect(service.monitor()).resolves.toMatchObject({ status: MonitorStatus.UNKNOW, cause: expect.any(Error) }) + await expect(service.check()).resolves.toMatchObject({ + harbor: { status: 'down', message: 'Erreur lors la requête', cause: expect.any(Error) }, + }) }) -}) \ No newline at end of file +}) diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index 28f3e25a77..72a0d87d25 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,9 +1,20 @@ import type { HarborConfig } from '../../config/harbor.config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' +import { z } from 'zod' import { harborConfigFactory } from '../../config/harbor.config' import { PLUGIN_NAME } from './registry.constants' -import { monitorHarborHealth } from './registry.utils' + +// /api/v2.0/health splits degraded from failed, unlike /api/v2.0/ping. +const CORE_COMPONENTS = new Set(['core', 'database', 'portal', 'registry', 'registryctl']) + +const harborHealthBodySchema = z.object({ + status: z.string(), + components: z.array(z.object({ + name: z.string(), + status: z.string(), + })).optional(), +}) @Injectable() export class RegistryHealthService { @@ -12,23 +23,26 @@ export class RegistryHealthService { @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} - // Legacy harbor monitor reads /health, not /ping, to split degraded from failed. - monitor() { - return monitorHarborHealth(() => { - const url = new URL('/api/v2.0/health', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() - return fetch(url, { method: 'GET', headers: this.authHeaders() }) - }) - } - async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) try { - const url = new URL('/api/v2.0/ping', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() + const url = new URL('/api/v2.0/health', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() const response = await fetch(url, { method: 'GET', headers: this.authHeaders() }) - if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) - return indicator.down({ httpStatus: response.status }) + if (response.status !== HttpStatus.OK) return indicator.down({ message: 'Fatal Error' }) + + const parsed = harborHealthBodySchema.safeParse(await response.json()) + if (!parsed.success) return indicator.down({ message: 'Erreur lors la requête' }) + if (parsed.data.status === 'healthy') return indicator.up() + + const failedCoreComponent = parsed.data.components?.some(component => + component.status === 'unhealthy' + && CORE_COMPONENTS.has(component.name), + ) + return failedCoreComponent + ? indicator.down({ message: 'Service en erreur' }) + : indicator.up({ message: 'Service dégradé' }) } catch (error) { - return indicator.down(error instanceof Error ? error.message : String(error)) + return indicator.down({ message: 'Erreur lors la requête', cause: error }) } } diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index c1f2b5c61a..778869ef13 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,6 +1,6 @@ import type { ProjectWithDetails } from './registry-datastore.service' import type { RegistryResponse } from './registry-http-client.service' -import { MonitorStatus, removeTrailingSlash } from '@cpn-console/shared' +import { removeTrailingSlash } from '@cpn-console/shared' import { HttpStatus } from '@nestjs/common' import z from 'zod' @@ -124,42 +124,3 @@ function parseUnit(unit: string) { return 0 } } - -export interface RegistryProbeOutcome { - status: MonitorStatus - message: string - cause?: unknown -} - -// Legacy harbor monitor reads /health, not /ping, to split degraded from failed. -const MONITOR_ERROR_MESSAGE = 'Erreur lors la requête' - -const CORE_COMPONENTS = new Set(['core', 'database', 'portal', 'registry', 'registryctl']) - -const harborHealthBodySchema = z.object({ - status: z.string(), - components: z.array(z.object({ - name: z.string(), - status: z.string(), - })).optional(), -}) - -export async function monitorHarborHealth(fetchHealth: () => Promise): Promise { - try { - const response = await fetchHealth() - if (response.status !== HttpStatus.OK) return { status: MonitorStatus.ERROR, message: 'Fatal Error' } - - const parsed = harborHealthBodySchema.safeParse(await response.json()) - if (!parsed.success) return { status: MonitorStatus.UNKNOW, message: MONITOR_ERROR_MESSAGE } - if (parsed.data.status === 'healthy') return { status: MonitorStatus.OK, message: MonitorStatus.OK } - - const failedCoreComponent = parsed.data.components?.some(component => - component.status === 'unhealthy' - && CORE_COMPONENTS.has(component.name), - ) - if (failedCoreComponent) return { status: MonitorStatus.ERROR, message: 'Service en erreur' } - return { status: MonitorStatus.WARNING, message: 'Service dégradé' } - } catch (error) { - return { status: MonitorStatus.UNKNOW, message: MONITOR_ERROR_MESSAGE, cause: error } - } -} diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index 5379338678..b71970e8d6 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -1,8 +1,8 @@ import type { Mock } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' import { MonitorStatus } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { DeepMockProxy } from 'vitest-mock-extended' import { mockDeep } from 'vitest-mock-extended' import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' @@ -36,9 +36,9 @@ describe('serviceMonitorService', () => { keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) argocd.check.mockResolvedValue({ argocd: { status: 'up', httpStatus: 200 } }) gitlab.check.mockResolvedValue({ gitlab: { status: 'up', httpStatus: 200 } }) - harbor.monitor.mockResolvedValue({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + harbor.check.mockResolvedValue({ harbor: { status: 'up' } }) nexus.check.mockResolvedValue({ nexus: { status: 'up', httpStatus: 200 } }) - sonarqube.monitor.mockResolvedValue({ status: MonitorStatus.OK, message: MonitorStatus.OK }) + sonarqube.check.mockResolvedValue({ sonarqube: { status: 'up' } }) vault.check.mockResolvedValue({ vault: { status: 'up', httpStatus: 200 } }) const moduleRef = await Test.createTestingModule({ providers: [ @@ -73,9 +73,9 @@ describe('serviceMonitorService', () => { ]) }) - it('passes the SonarQube and Harbor monitor verdicts through', async () => { - sonarqube.monitor.mockResolvedValue({ status: MonitorStatus.WARNING, message: 'Service dégradé' }) - harbor.monitor.mockResolvedValue({ status: MonitorStatus.ERROR, message: 'Service en erreur' }) + it('passes the SonarQube and Harbor degraded and failed verdicts through', async () => { + sonarqube.check.mockResolvedValue({ sonarqube: { status: 'up', message: 'Service dégradé' } }) + harbor.check.mockResolvedValue({ harbor: { status: 'down', message: 'Service en erreur' } }) const health = await service.refreshServiceHealth() @@ -106,8 +106,8 @@ describe('serviceMonitorService', () => { await service.refreshServiceHealth() expect(keycloak.check).toHaveBeenCalledTimes(2) - expect(sonarqube.monitor).toHaveBeenCalledTimes(2) - expect(harbor.monitor).toHaveBeenCalledTimes(2) + expect(sonarqube.check).toHaveBeenCalledTimes(2) + expect(harbor.check).toHaveBeenCalledTimes(2) }) it('probes once and schedules the periodic refresh on init', async () => { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index f15ecb8fc4..049a41b3b0 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -47,10 +47,10 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { const probes: Array<[string, (() => Promise) | undefined]> = [ ['ArgoCD', argocdHealth && (() => argocdHealth.check().then(result => fromHealthCheck(result, 'argocd')))], ['Gitlab', gitlabHealth && (() => gitlabHealth.check().then(result => fromHealthCheck(result, 'gitlab')))], - ['Harbor', harborHealth && (() => harborHealth.monitor())], + ['Harbor', harborHealth && (() => harborHealth.check().then(result => fromHealthCheck(result, 'harbor')))], ['Keycloak', () => this.keycloak.check().then(result => fromHealthCheck(result, 'keycloak'))], ['Nexus', nexusHealth && (() => nexusHealth.check().then(result => fromHealthCheck(result, 'nexus')))], - ['SonarQube', sonarqubeHealth && (() => sonarqubeHealth.monitor())], + ['SonarQube', sonarqubeHealth && (() => sonarqubeHealth.check().then(result => fromHealthCheck(result, 'sonarqube')))], ['Vault', vaultHealth && (() => vaultHealth.check().then(result => fromHealthCheck(result, 'vault')))], ] diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts index f9a8aa9aad..e326d1237b 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -11,6 +11,7 @@ export interface ProbeOutcome { interface ProbeDetail { status: string message?: string + cause?: unknown } function isProbeDetail(detail: unknown): detail is ProbeDetail { @@ -24,10 +25,15 @@ export function fromHealthCheck(result: unknown, key: string): ProbeOutcome { } const up = detail.status === 'up' + if (up) { + return detail.cause !== undefined || detail.message !== undefined + ? { status: detail.cause !== undefined ? MonitorStatus.UNKNOW : MonitorStatus.WARNING, message: detail.message ?? MonitorStatus.WARNING } + : { status: MonitorStatus.OK, message: MonitorStatus.OK } + } return { - status: up ? MonitorStatus.OK : MonitorStatus.ERROR, - message: up ? MonitorStatus.OK : detail.message ?? ERROR_MESSAGE, - ...(up ? {} : { cause: detail.message ?? ERROR_MESSAGE }), + status: MonitorStatus.ERROR, + message: detail.message ?? ERROR_MESSAGE, + cause: toCause(detail.cause ?? detail.message ?? ERROR_MESSAGE), } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts index eb3b50de14..8d8ad629dc 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts @@ -1,5 +1,4 @@ import type { ConfigType } from '@nestjs/config' -import { MonitorStatus } from '@cpn-console/shared' import { HealthIndicatorService } from '@nestjs/terminus' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' @@ -30,7 +29,7 @@ describe('sonarqubeHealthService', () => { providers: [ SonarqubeHealthService, { provide: sonarqubeConfigFactory.KEY, useValue: config }, - { provide: HealthIndicatorService, useValue: mockDeep() }, + HealthIndicatorService, ], }).compile() @@ -40,30 +39,32 @@ describe('sonarqubeHealthService', () => { afterAll(() => server.close()) it.each([ - ['GREEN', MonitorStatus.OK, MonitorStatus.OK], - ['YELLOW', MonitorStatus.WARNING, 'Service dégradé'], - ['RED', MonitorStatus.ERROR, 'Service en panne'], - ])('maps SonarQube health %s to %s', async (health, status, message) => { + ['GREEN', { status: 'up' }], + ['YELLOW', { status: 'up', message: 'Service dégradé' }], + ['RED', { status: 'down', message: 'Service en panne' }], + ])('maps SonarQube health %s', async (health, expected) => { server.use(http.get(healthUrl, () => HttpResponse.json({ health, causes: [] }))) - await expect(service.monitor()).resolves.toEqual({ status, message }) + await expect(service.check()).resolves.toEqual({ sonarqube: expected }) }) - it('reports UNKNOW when the API answers without a health field', async () => { + it('reports down when the API answers without a health field', async () => { server.use(http.get(healthUrl, () => HttpResponse.json({}))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.UNKNOW, message: 'Erreur lors la requête' }) + await expect(service.check()).resolves.toEqual({ sonarqube: { status: 'down', message: 'Erreur lors la requête' } }) }) - it('reports UNKNOW when the API answers with a non-200 status', async () => { + it('reports down when the API answers with a non-200 status', async () => { server.use(http.get(healthUrl, () => new HttpResponse(null, { status: 503 }))) - await expect(service.monitor()).resolves.toEqual({ status: MonitorStatus.UNKNOW, message: 'Erreur lors la requête' }) + await expect(service.check()).resolves.toEqual({ sonarqube: { status: 'down', message: 'Erreur lors la requête' } }) }) - it('reports UNKNOW with the cause when the request fails', async () => { + it('reports down with the cause when the request fails', async () => { server.use(http.get(healthUrl, () => HttpResponse.error())) - await expect(service.monitor()).resolves.toMatchObject({ status: MonitorStatus.UNKNOW, cause: expect.any(Error) }) + await expect(service.check()).resolves.toMatchObject({ + sonarqube: { status: 'down', message: 'Erreur lors la requête', cause: expect.any(Error) }, + }) }) -}) \ No newline at end of file +}) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 2e7aa304ec..b966da1310 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,16 +1,9 @@ import type { ConfigType } from '@nestjs/config' -import { MonitorStatus } from '@cpn-console/shared' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { isRecord } from '../../utils/record.utils' -import { REQUEST_ERROR_MESSAGE, SONARQUBE_HEALTH_STATUS } from './sonarqube.constants' - -export interface SonarqubeProbeOutcome { - status: MonitorStatus - message: string - cause?: unknown -} +import { PLUGIN_NAME, REQUEST_ERROR_MESSAGE } from './sonarqube.constants' @Injectable() export class SonarqubeHealthService { @@ -19,29 +12,20 @@ export class SonarqubeHealthService { @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} - // Legacy sonarqube monitor maps /system/health, not the bare 200, to the verdict. - async monitor(): Promise { + async check() { + const indicator = this.healthIndicator.check(PLUGIN_NAME) try { const response = await fetch(this.healthUrl(), { headers: this.authHeaders() }) - if (response.status !== HttpStatus.OK) return { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE } + if (response.status !== HttpStatus.OK) return indicator.down({ message: REQUEST_ERROR_MESSAGE }) const data: unknown = await response.json() const health = isRecord(data) && typeof data.health === 'string' ? data.health : undefined - const mapped = health ? SONARQUBE_HEALTH_STATUS[health] : undefined - return mapped ?? { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE } - } catch (error) { - return { status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE, cause: error } - } - } - - async check(key: string) { - const indicator = this.healthIndicator.check(key) - try { - const response = await fetch(this.healthUrl(), { headers: this.authHeaders() }) - if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) - return indicator.down({ httpStatus: response.status }) + if (health === 'GREEN') return indicator.up() + if (health === 'YELLOW') return indicator.up({ message: 'Service dégradé' }) + if (health === 'RED') return indicator.down({ message: 'Service en panne' }) + return indicator.down({ message: REQUEST_ERROR_MESSAGE }) } catch (error) { - return indicator.down(error instanceof Error ? error.message : String(error)) + return indicator.down({ message: REQUEST_ERROR_MESSAGE, cause: error }) } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts index 4ca10b8670..66a5db7df9 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -1,5 +1,3 @@ -import { MonitorStatus } from '@cpn-console/shared' - export const PLUGIN_NAME = 'sonarqube' export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' @@ -59,9 +57,3 @@ export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' // Health monitor messages export const REQUEST_ERROR_MESSAGE = 'Erreur lors la requête' - -export const SONARQUBE_HEALTH_STATUS: Record = { - GREEN: { status: MonitorStatus.OK, message: MonitorStatus.OK }, - YELLOW: { status: MonitorStatus.WARNING, message: 'Service dégradé' }, - RED: { status: MonitorStatus.ERROR, message: 'Service en panne' }, -} From b53856afbbf0aba87e4939c37d2c439851f4efbe Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 17:08:16 +0200 Subject: [PATCH 08/16] refactor(service-monitor): store last-known statuses in CACHE_MANAGER Replace the private MonitoredService.lastStatus array and snapshot() with the module cache (CacheModule.register + @Inject(CACHE_MANAGER)), matching the registry and keycloak-secret-provider pattern. Reads fall back to the pending status until the first probe pass writes a key. Co-authored-by: Automata --- .../service-monitor.controller.spec.ts | 8 ++- .../service-monitor/service-monitor.module.ts | 2 + .../service-monitor.service.spec.ts | 18 +++--- .../service-monitor.service.ts | 57 +++++++++++-------- 4 files changed, 50 insertions(+), 35 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts index d8d48b98eb..d2c9283c70 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts @@ -1,5 +1,7 @@ import type { TestingModule } from '@nestjs/testing' import type { MockProxy } from 'vitest-mock-extended' +import type { ServiceHealth } from './service-monitor.service' +import { MonitorStatus } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' @@ -33,7 +35,7 @@ describe('serviceMonitorController', () => { }) it('delegates health-services to the service', async () => { - const health = [{ name: 'keycloak', status: 'up' }] + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] service.getServiceHealth.mockResolvedValue(health) await expect(controller.getServiceHealth()).resolves.toBe(health) @@ -41,7 +43,7 @@ describe('serviceMonitorController', () => { }) it('delegates complete-services to the service', async () => { - const health = [{ name: 'keycloak', status: 'up' }] + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] service.getCompleteServiceHealth.mockResolvedValue(health) await expect(controller.getCompleteServiceHealth()).resolves.toBe(health) @@ -49,7 +51,7 @@ describe('serviceMonitorController', () => { }) it('delegates refresh-services to the service', async () => { - const health = [{ name: 'keycloak', status: 'up' }] + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] service.refreshServiceHealth.mockResolvedValue(health) expect(await controller.refreshServiceHealth()).toBe(health) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts index 913711042e..dbd3d5f851 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts @@ -1,3 +1,4 @@ +import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' import { ConditionalModule } from '@nestjs/config' import { ArgoCDModule } from '../argocd/argocd.module' @@ -14,6 +15,7 @@ import { ServiceMonitorService } from './service-monitor.service' @Module({ imports: [ + CacheModule.register(), AuthModule, UserPermissionModule, KeycloakModule, diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index b71970e8d6..152269705c 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -1,7 +1,9 @@ import type { Mock } from 'vitest' import type { DeepMockProxy } from 'vitest-mock-extended' import { MonitorStatus } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { ArgoCDHealthService } from '../argocd/argocd-health.service' @@ -43,6 +45,7 @@ describe('serviceMonitorService', () => { const moduleRef = await Test.createTestingModule({ providers: [ ServiceMonitorService, + { provide: CACHE_MANAGER, useValue: createCache() }, { provide: KeycloakHealthService, useValue: keycloak }, { provide: ArgoCDHealthService, useValue: argocd }, { provide: GitlabHealthService, useValue: gitlab }, @@ -87,8 +90,8 @@ describe('serviceMonitorService', () => { await service.refreshServiceHealth() keycloak.check.mockClear() - service.getServiceHealth() - service.getServiceHealth() + await service.getServiceHealth() + await service.getServiceHealth() expect(keycloak.check).not.toHaveBeenCalled() }) @@ -97,8 +100,8 @@ describe('serviceMonitorService', () => { keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 503, message: 'connection refused' } }) await service.refreshServiceHealth() - expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'connection refused' })) - expect(service.getServiceHealth().every(entry => !('cause' in entry))).toBe(true) + expect(await service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'connection refused' })) + expect((await service.getServiceHealth()).every(entry => !('cause' in entry))).toBe(true) }) it('refreshServiceHealth re-probes every enabled service', async () => { @@ -127,6 +130,7 @@ describe('serviceMonitorService', () => { const moduleRef = await Test.createTestingModule({ providers: [ ServiceMonitorService, + { provide: CACHE_MANAGER, useValue: createCache() }, { provide: KeycloakHealthService, useValue: keycloak }, ], }).compile() @@ -143,8 +147,8 @@ describe('serviceMonitorService', () => { expect(health.map(entry => entry.name)).toEqual(['Keycloak']) }) - it('serves the pending state until the first probe', () => { - expect(service.getServiceHealth()).toEqual([ + it('serves the pending state until the first probe', async () => { + expect(await service.getServiceHealth()).toEqual([ { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'En attente d\'une première vérification' }, ]) }) @@ -157,7 +161,7 @@ describe('serviceMonitorService', () => { expect(health).toEqual([ { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête' }, ]) - expect(service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) + expect(await service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) }) it('reports UNKNOW when the probe payload has no status field', async () => { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index 049a41b3b0..7a8efc5d81 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -1,7 +1,9 @@ import type { MonitorInfos } from '@cpn-console/shared' import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import type { Cache } from 'cache-manager' import type { ProbeOutcome } from './service-monitor.utils' import { MonitorStatus } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Optional } from '@nestjs/common' import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' @@ -18,9 +20,10 @@ export type ServiceHealth = MonitorInfos & { name: string } interface MonitoredService { name: string probe: () => Promise - lastStatus: MonitorInfos } +const CACHE_KEY = 'service-monitor:statuses' + @Injectable() export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { private readonly interval: number = INTERVAL_MS @@ -28,6 +31,7 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { private timer: NodeJS.Timeout | undefined constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, @@ -54,20 +58,20 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { ['Vault', vaultHealth && (() => vaultHealth.check().then(result => fromHealthCheck(result, 'vault')))], ] - this.services = probes.flatMap(([name, probe]) => probe ? [this.track(name, probe)] : []) + this.services = probes.flatMap(([name, probe]) => probe ? [{ name, probe }] : []) } - getServiceHealth(): ServiceHealth[] { - return this.snapshot(false) + async getServiceHealth(): Promise { + return this.read(false) } - getCompleteServiceHealth(): ServiceHealth[] { - return this.snapshot(true) + async getCompleteServiceHealth(): Promise { + return this.read(true) } async refreshServiceHealth(): Promise { await this.runAll() - return this.snapshot(false) + return this.read(false) } onModuleInit(): void { @@ -89,37 +93,40 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { await Promise.all(this.services.map(async (service) => { try { const outcome = await service.probe() - service.lastStatus = { ...outcome, interval: this.interval, lastUpdateTimestamp: Date.now() } + await this.writeStatus(service.name, { ...outcome, interval: this.interval, lastUpdateTimestamp: Date.now() }) } catch (error) { - service.lastStatus = { + await this.writeStatus(service.name, { interval: this.interval, lastUpdateTimestamp: Date.now(), status: MonitorStatus.UNKNOW, message: REQUEST_ERROR_MESSAGE, cause: error, - } + }) } })) } - private snapshot(withCause: boolean): ServiceHealth[] { - return this.services.map(({ name, lastStatus }) => { - const { cause, ...health } = lastStatus - return { name, ...health, ...(withCause && cause !== undefined ? { cause: toCause(cause) } : {}) } - }) + private async read(withCause: boolean): Promise { + const statuses = await Promise.all(this.services.map(async ({ name }) => { + const status = await this.cache.get(`${CACHE_KEY}:${name}`) + return { name, ...this.pendingStatus(), ...status } + })) + return withCause + ? statuses.map(({ cause, ...health }) => ({ ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) })) + : statuses.map(({ cause, ...health }) => health) + } + + private async writeStatus(name: string, status: MonitorInfos): Promise { + await this.cache.set(`${CACHE_KEY}:${name}`, status) } - private track(name: string, probe: () => Promise): MonitoredService { + private pendingStatus(): MonitorInfos { return { - name, - probe, - lastStatus: { - interval: this.interval, - lastUpdateTimestamp: Date.now(), - status: MonitorStatus.UNKNOW, - message: PENDING_MESSAGE, - cause: 'App just started', - }, + interval: this.interval, + lastUpdateTimestamp: Date.now(), + status: MonitorStatus.UNKNOW, + message: PENDING_MESSAGE, + cause: 'App just started', } } } From 6a10499eeea42fcfe06321cd954261ceecdd3832 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 17:41:51 +0200 Subject: [PATCH 09/16] refactor(service-monitor): drive the periodic refresh with @Cron Replace the hand-rolled setInterval timer (and its OnModuleDestroy cleanup) with @Cron(CronExpression.EVERY_5_MINUTES) on runAll, matching INTERVAL_MS; ScheduleModule.forRoot() is already global. onModuleInit keeps the first pass. Co-authored-by: Automata --- .../service-monitor.service.spec.ts | 8 ++----- .../service-monitor.service.ts | 21 ++++++------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index 152269705c..0b4b146d87 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -4,7 +4,7 @@ import { MonitorStatus } from '@cpn-console/shared' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Test } from '@nestjs/testing' import { createCache } from 'cache-manager' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' @@ -58,8 +58,6 @@ describe('serviceMonitorService', () => { service = moduleRef.get(ServiceMonitorService) }) - afterEach(() => service.onModuleDestroy()) - it('aggregates every injected probe', async () => { keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 500 } }) @@ -113,7 +111,7 @@ describe('serviceMonitorService', () => { expect(harbor.check).toHaveBeenCalledTimes(2) }) - it('probes once and schedules the periodic refresh on init', async () => { + it('probes once on init', async () => { service.onModuleInit() await new Promise(resolve => setImmediate(resolve)) @@ -137,8 +135,6 @@ describe('serviceMonitorService', () => { service = moduleRef.get(ServiceMonitorService) }) - afterEach(() => service.onModuleDestroy()) - it('skips unregistered probes', async () => { keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index 7a8efc5d81..d06ff9b500 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -1,10 +1,11 @@ import type { MonitorInfos } from '@cpn-console/shared' -import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import type { OnModuleInit } from '@nestjs/common' import type { Cache } from 'cache-manager' import type { ProbeOutcome } from './service-monitor.utils' import { MonitorStatus } from '@cpn-console/shared' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Optional } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' import { ArgoCDHealthService } from '../argocd/argocd-health.service' import { GitlabHealthService } from '../gitlab/gitlab-health.service' import { KeycloakHealthService } from '../keycloak/keycloak-health.service' @@ -25,10 +26,9 @@ interface MonitoredService { const CACHE_KEY = 'service-monitor:statuses' @Injectable() -export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { +export class ServiceMonitorService implements OnModuleInit { private readonly interval: number = INTERVAL_MS private readonly services: MonitoredService[] - private timer: NodeJS.Timeout | undefined constructor( @Inject(CACHE_MANAGER) private readonly cache: Cache, @@ -75,20 +75,11 @@ export class ServiceMonitorService implements OnModuleInit, OnModuleDestroy { } onModuleInit(): void { - void this.refresh() - } - - onModuleDestroy(): void { - if (this.timer) clearInterval(this.timer) - } - - // Legacy Monitor.refresh(): serve the last known state between periodic passes. - private async refresh(): Promise { - if (this.timer) clearInterval(this.timer) - this.timer = setInterval(() => void this.runAll(), this.interval) - await this.runAll() + void this.runAll() } + // Legacy Monitor.refresh(): the cron refreshes the cached statuses periodically. + @Cron(CronExpression.EVERY_5_MINUTES) private async runAll(): Promise { await Promise.all(this.services.map(async (service) => { try { From f543b78b813cba7cf12221b5a15a2e2bbeb23d7d Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 17:49:36 +0200 Subject: [PATCH 10/16] refactor(service-monitor): always return cause in the service The service now returns MonitorInfos.cause on every read; the public GET /health-services strips it in the controller, where the admin-only boundary lives. getCompleteServiceHealth and read(withCause) collapse into getServiceHealth; a cached status no longer merges over the pending fallback, so an OK entry never inherits the pending cause. Co-authored-by: Automata --- .../service-monitor.controller.spec.ts | 12 +++++----- .../service-monitor.controller.ts | 5 +++-- .../service-monitor.service.spec.ts | 15 ++++++------- .../service-monitor.service.ts | 22 +++++-------------- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts index d2c9283c70..744e0e6af0 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts @@ -34,20 +34,20 @@ describe('serviceMonitorController', () => { expect(controller).toBeDefined() }) - it('delegates health-services to the service', async () => { - const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] + it('strips the cause from the public route', async () => { + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0, cause: 'connection refused' }] service.getServiceHealth.mockResolvedValue(health) - await expect(controller.getServiceHealth()).resolves.toBe(health) + await expect(controller.getServiceHealth()).resolves.toEqual([{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0 }]) expect(service.getServiceHealth).toHaveBeenCalledTimes(1) }) - it('delegates complete-services to the service', async () => { + it('delegates complete-services to the service without stripping', async () => { const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] - service.getCompleteServiceHealth.mockResolvedValue(health) + service.getServiceHealth.mockResolvedValue(health) await expect(controller.getCompleteServiceHealth()).resolves.toBe(health) - expect(service.getCompleteServiceHealth).toHaveBeenCalledTimes(1) + expect(service.getServiceHealth).toHaveBeenCalledTimes(1) }) it('delegates refresh-services to the service', async () => { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts index f042fd17fa..3545441577 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -11,14 +11,15 @@ export class ServiceMonitorController { @Get('health-services') getServiceHealth() { - return this.service.getServiceHealth() + // cause is reserved for admins (MonitorInfos.cause) + return this.service.getServiceHealth().then(health => health.map(({ cause, ...service }) => service)) } @Get('complete-services') @UseGuards(UserGuard) @RequireAdminPermission('ListSystem') getCompleteServiceHealth() { - return this.service.getCompleteServiceHealth() + return this.service.getServiceHealth() } @Get('refresh-services') diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index 0b4b146d87..6f1ee3316c 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -67,7 +67,7 @@ describe('serviceMonitorService', () => { { name: 'ArgoCD', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, { name: 'Gitlab', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, { name: 'Harbor', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, - { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Service en erreur' }, + { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Service en erreur', cause: 'Service en erreur' }, { name: 'Nexus', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, { name: 'SonarQube', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, { name: 'Vault', status: MonitorStatus.OK, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: MonitorStatus.OK }, @@ -94,12 +94,11 @@ describe('serviceMonitorService', () => { expect(keycloak.check).not.toHaveBeenCalled() }) - it('exposes the cause on the admin route, never on the public one', async () => { + it('always returns the cause', async () => { keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 503, message: 'connection refused' } }) await service.refreshServiceHealth() - expect(await service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'connection refused' })) - expect((await service.getServiceHealth()).every(entry => !('cause' in entry))).toBe(true) + expect(await service.getServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'connection refused' })) }) it('refreshServiceHealth re-probes every enabled service', async () => { @@ -145,7 +144,7 @@ describe('serviceMonitorService', () => { it('serves the pending state until the first probe', async () => { expect(await service.getServiceHealth()).toEqual([ - { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'En attente d\'une première vérification' }, + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'En attente d\'une première vérification', cause: 'App just started' }, ]) }) @@ -155,9 +154,9 @@ describe('serviceMonitorService', () => { const health = await service.refreshServiceHealth() expect(health).toEqual([ - { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête' }, + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête', cause: 'boom' }, ]) - expect(await service.getCompleteServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) + expect(await service.getServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) }) it('reports UNKNOW when the probe payload has no status field', async () => { @@ -176,7 +175,7 @@ describe('serviceMonitorService', () => { const health = await service.refreshServiceHealth() expect(health).toEqual([ - { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'connection refused' }, + { name: 'Keycloak', status: MonitorStatus.ERROR, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'connection refused', cause: 'connection refused' }, ]) }) }) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index d06ff9b500..1fe535f20b 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -62,16 +62,16 @@ export class ServiceMonitorService implements OnModuleInit { } async getServiceHealth(): Promise { - return this.read(false) - } - - async getCompleteServiceHealth(): Promise { - return this.read(true) + const statuses = await Promise.all(this.services.map(async ({ name }) => { + const cached = await this.cache.get(`${CACHE_KEY}:${name}`) + return { name, ...(cached ?? this.pendingStatus()) } + })) + return statuses.map(({ cause, ...health }) => ({ ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) })) } async refreshServiceHealth(): Promise { await this.runAll() - return this.read(false) + return this.getServiceHealth() } onModuleInit(): void { @@ -97,16 +97,6 @@ export class ServiceMonitorService implements OnModuleInit { })) } - private async read(withCause: boolean): Promise { - const statuses = await Promise.all(this.services.map(async ({ name }) => { - const status = await this.cache.get(`${CACHE_KEY}:${name}`) - return { name, ...this.pendingStatus(), ...status } - })) - return withCause - ? statuses.map(({ cause, ...health }) => ({ ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) })) - : statuses.map(({ cause, ...health }) => health) - } - private async writeStatus(name: string, status: MonitorInfos): Promise { await this.cache.set(`${CACHE_KEY}:${name}`, status) } From cbe6bfbded7967167216c58f6a7b51af65bf9d33 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 17:55:41 +0200 Subject: [PATCH 11/16] refactor(service-monitor): move cause formatting to the controller The service returns the raw cached cause; toCause stringification now happens in toServiceHealth at the presentation layer, alongside the public-route stripping. fromHealthCheck stays in utils (probe parsing). Co-authored-by: Automata --- .../service-monitor.controller.spec.ts | 12 ++++++------ .../service-monitor/service-monitor.controller.ts | 10 ++++++++-- .../service-monitor/service-monitor.service.spec.ts | 4 ++-- .../service-monitor/service-monitor.service.ts | 4 ++-- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts index 744e0e6af0..a20b584e3f 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.spec.ts @@ -42,19 +42,19 @@ describe('serviceMonitorController', () => { expect(service.getServiceHealth).toHaveBeenCalledTimes(1) }) - it('delegates complete-services to the service without stripping', async () => { - const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] + it('delegates complete-services to the service, formatting the cause', async () => { + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0, cause: new Error('boom') }] service.getServiceHealth.mockResolvedValue(health) - await expect(controller.getCompleteServiceHealth()).resolves.toBe(health) + await expect(controller.getCompleteServiceHealth()).resolves.toEqual([{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0, cause: 'boom' }]) expect(service.getServiceHealth).toHaveBeenCalledTimes(1) }) - it('delegates refresh-services to the service', async () => { - const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.OK, message: MonitorStatus.OK, interval: 1000, lastUpdateTimestamp: 0 }] + it('delegates refresh-services to the service, formatting the cause', async () => { + const health: ServiceHealth[] = [{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0, cause: new Error('boom') }] service.refreshServiceHealth.mockResolvedValue(health) - expect(await controller.refreshServiceHealth()).toBe(health) + await expect(controller.refreshServiceHealth()).resolves.toEqual([{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0, cause: 'boom' }]) expect(service.refreshServiceHealth).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts index 3545441577..93dd9b886f 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -1,7 +1,13 @@ +import type { ServiceHealth } from './service-monitor.service' import { Controller, Get, Inject, UseGuards } from '@nestjs/common' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { ServiceMonitorService } from './service-monitor.service' +import { toCause } from './service-monitor.utils' + +function toServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { + return { ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) } +} @Controller('api/v1') export class ServiceMonitorController { @@ -19,13 +25,13 @@ export class ServiceMonitorController { @UseGuards(UserGuard) @RequireAdminPermission('ListSystem') getCompleteServiceHealth() { - return this.service.getServiceHealth() + return this.service.getServiceHealth().then(health => health.map(toServiceHealth)) } @Get('refresh-services') @UseGuards(UserGuard) @RequireAdminPermission('ManageSystem') refreshServiceHealth() { - return this.service.refreshServiceHealth() + return this.service.refreshServiceHealth().then(health => health.map(toServiceHealth)) } } diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts index 6f1ee3316c..2c40ec2006 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -154,9 +154,9 @@ describe('serviceMonitorService', () => { const health = await service.refreshServiceHealth() expect(health).toEqual([ - { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête', cause: 'boom' }, + { name: 'Keycloak', status: MonitorStatus.UNKNOW, interval: INTERVAL_MS, lastUpdateTimestamp: expect.any(Number), message: 'Erreur lors la requête', cause: new Error('boom') }, ]) - expect(await service.getServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: 'boom' })) + expect(await service.getServiceHealth()).toContainEqual(expect.objectContaining({ name: 'Keycloak', cause: new Error('boom') })) }) it('reports UNKNOW when the probe payload has no status field', async () => { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index 1fe535f20b..283edf104f 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -14,7 +14,7 @@ import { RegistryHealthService } from '../registry/registry-health.service' import { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' import { VaultHealthService } from '../vault/vault-health.service' import { INTERVAL_MS, PENDING_MESSAGE, REQUEST_ERROR_MESSAGE } from './service-monitor.constants' -import { fromHealthCheck, toCause } from './service-monitor.utils' +import { fromHealthCheck } from './service-monitor.utils' export type ServiceHealth = MonitorInfos & { name: string } @@ -66,7 +66,7 @@ export class ServiceMonitorService implements OnModuleInit { const cached = await this.cache.get(`${CACHE_KEY}:${name}`) return { name, ...(cached ?? this.pendingStatus()) } })) - return statuses.map(({ cause, ...health }) => ({ ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) })) + return statuses } async refreshServiceHealth(): Promise { From bab4e6dfd96c47f956b60d0bd83898c901235b84 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 20:34:36 +0200 Subject: [PATCH 12/16] refactor(service-monitor): name the public-route cause stripping toPublicServiceHealth replaces the inline destructure in health-services, keeping the admin-only cause rationale next to its siblings. Co-authored-by: Automata --- .../modules/service-monitor/service-monitor.controller.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts index 93dd9b886f..5e835ccad9 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -9,6 +9,11 @@ function toServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { return { ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) } } +// cause is reserved for admins (MonitorInfos.cause) +function toPublicServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { + return health +} + @Controller('api/v1') export class ServiceMonitorController { constructor( @@ -17,8 +22,7 @@ export class ServiceMonitorController { @Get('health-services') getServiceHealth() { - // cause is reserved for admins (MonitorInfos.cause) - return this.service.getServiceHealth().then(health => health.map(({ cause, ...service }) => service)) + return this.service.getServiceHealth().then(health => health.map(toPublicServiceHealth)) } @Get('complete-services') From 313d4963fd3913cb08966e18d98a86f8ea0acef4 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 12 Oct 2026 21:28:26 +0200 Subject: [PATCH 13/16] docs(service-monitor): compress the admin-only cause comment Co-authored-by: Automata --- .../src/modules/service-monitor/service-monitor.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts index 5e835ccad9..eb9c9c658e 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -9,7 +9,7 @@ function toServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { return { ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) } } -// cause is reserved for admins (MonitorInfos.cause) +// MonitorInfos.cause is admin-only function toPublicServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { return health } From d8209026696d6a0dad745b05531f4c75c35a94b7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 13 Oct 2026 11:24:45 +0200 Subject: [PATCH 14/16] docs(service-monitor): drop the legacy cron narration comment Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: If0057ad2b52eb20390f9f7649e26eb4c6a6a6964 --- .../src/modules/service-monitor/service-monitor.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts index 283edf104f..ef3e7306c0 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -78,7 +78,6 @@ export class ServiceMonitorService implements OnModuleInit { void this.runAll() } - // Legacy Monitor.refresh(): the cron refreshes the cached statuses periodically. @Cron(CronExpression.EVERY_5_MINUTES) private async runAll(): Promise { await Promise.all(this.services.map(async (service) => { From 5f0bc297d15e4cb55cf25aba7965ff2e7c17d410 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Tue, 13 Oct 2026 11:44:17 +0200 Subject: [PATCH 15/16] fix(sonarqube): send the raw api token under Bearer in the health probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe base64-encoded the token into a Bearer header — a Basic-auth payload under the Bearer scheme, which SonarQube rejects with 401, so the monitor reported UNKNOW on every pass. Send the raw token, matching sonarqube-http-client.service.ts. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I3eb3aa5b70626b11321b2c40339b58916a6a6964 --- .../sonarqube/sonarqube-health.service.spec.ts | 11 +++++++++++ .../src/modules/sonarqube/sonarqube-health.service.ts | 3 +-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts index 8d8ad629dc..0aa051f3e1 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.spec.ts @@ -48,6 +48,17 @@ describe('sonarqubeHealthService', () => { await expect(service.check()).resolves.toEqual({ sonarqube: expected }) }) + it('sends the raw api token under Bearer', async () => { + let authorization: string | undefined + server.use(http.get(healthUrl, ({ request }) => { + authorization = request.headers.get('authorization') ?? undefined + return HttpResponse.json({ health: 'GREEN', causes: [] }) + })) + + await expect(service.check()).resolves.toEqual({ sonarqube: { status: 'up' } }) + expect(authorization).toBe('Bearer my-token') + }) + it('reports down when the API answers without a health field', async () => { server.use(http.get(healthUrl, () => HttpResponse.json({}))) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index b966da1310..11816389d0 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -34,7 +34,6 @@ export class SonarqubeHealthService { } private authHeaders(): Record { - const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') - return { Authorization: `Bearer ${bearerToken}` } + return { Authorization: `Bearer ${this.sonarqubeConfig.apiToken}` } } } From 916bea54db9713bf4582a42e93df73d783ca50ff Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 14 Oct 2026 15:40:13 +0200 Subject: [PATCH 16/16] refactor(service-monitor): move the health mappers to utils Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Icd0efdbd5cd254d3f6a0bf09a700527a6a6a6964 --- .../service-monitor/service-monitor.controller.ts | 12 +----------- .../modules/service-monitor/service-monitor.utils.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts index eb9c9c658e..1ad3110e27 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts @@ -1,18 +1,8 @@ -import type { ServiceHealth } from './service-monitor.service' import { Controller, Get, Inject, UseGuards } from '@nestjs/common' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { ServiceMonitorService } from './service-monitor.service' -import { toCause } from './service-monitor.utils' - -function toServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { - return { ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) } -} - -// MonitorInfos.cause is admin-only -function toPublicServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { - return health -} +import { toPublicServiceHealth, toServiceHealth } from './service-monitor.utils' @Controller('api/v1') export class ServiceMonitorController { diff --git a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts index e326d1237b..8e954ec534 100644 --- a/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.utils.ts @@ -1,3 +1,4 @@ +import type { ServiceHealth } from './service-monitor.service' import { MonitorStatus } from '@cpn-console/shared' import { isRecord } from '../../utils/record.utils' import { ERROR_MESSAGE } from './service-monitor.constants' @@ -40,3 +41,12 @@ export function fromHealthCheck(result: unknown, key: string): ProbeOutcome { export function toCause(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause) } + +export function toServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { + return { ...health, ...(cause !== undefined ? { cause: toCause(cause) } : {}) } +} + +// MonitorInfos.cause is admin-only +export function toPublicServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth { + return health +}