diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index b2ab4670f0..7620e1a7d1 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -109,6 +109,33 @@ server { } # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/v1/health-services { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/v1/complete-services { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/v1/refresh-services { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /api/ { proxy_pass http://server-legacy; proxy_http_version 1.1; diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 2e883a6823..dd1f597cf2 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 4a03fb39d2..ff78167003 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -20,6 +20,7 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' +import { ServiceMonitorModule } from './modules/service-monitor/service-monitor.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -48,6 +49,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectServicesModule, RepositoryModule, ScheduleModule.forRoot(), + ServiceMonitorModule, SystemConfigModule, SystemSettingsModule, VersionModule, 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..4ec80d3ca0 --- /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') + async 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..1db95168d6 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.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: [ + 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..c0afaedb76 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.spec.ts @@ -0,0 +1,59 @@ +import type { ArgoCDHealthService } from '../argocd/argocd-health.service' +import type { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import type { SonarqubeHealthService } from '../sonarqube/sonarqube-health.service' +import { describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ServiceMonitorService } from './service-monitor.service' + +describe('serviceMonitorService', () => { + it('aggregates every injected probe', async () => { + const argocd = mockDeep() + argocd.check.mockResolvedValue({ argocd: { status: 'up', httpStatus: 200 } }) + const keycloak = mockDeep() + keycloak.check.mockResolvedValue({ keycloak: { status: 'down', httpStatus: 500 } }) + const service = new ServiceMonitorService(keycloak, argocd, undefined, undefined, undefined, undefined, undefined) + + const health = await service.getServiceHealth() + + expect(health).toEqual([ + { name: 'ArgoCD', status: 'OK', interval: expect.any(Number), lastUpdateTimestamp: expect.any(Number), message: 'OK' }, + { name: 'Keycloak', status: 'En échec', interval: expect.any(Number), lastUpdateTimestamp: expect.any(Number), message: 'Service en erreur', cause: 'Service en erreur' }, + ]) + }) + + it('skips disabled probes', async () => { + const keycloak = mockDeep() + keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) + const service = new ServiceMonitorService(keycloak, undefined, undefined, undefined, undefined, undefined, undefined) + + const health = await service.getServiceHealth() + + expect(health.map(entry => entry.name)).toEqual(['Keycloak']) + }) + + it('reports a failing probe as En échec with its cause', async () => { + const keycloak = mockDeep() + keycloak.check.mockRejectedValue(new Error('boom')) + const service = new ServiceMonitorService(keycloak, undefined, undefined, undefined, undefined, undefined, undefined) + + const health = await service.getCompleteServiceHealth() + + expect(health).toEqual([ + { name: 'Keycloak', status: 'En échec', interval: expect.any(Number), lastUpdateTimestamp: expect.any(Number), message: 'boom', cause: 'boom' }, + ]) + }) + + it('refreshServiceHealth re-probes every enabled service', async () => { + const keycloak = mockDeep() + keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) + const sonarqube = mockDeep() + sonarqube.check.mockResolvedValue({ sonarqube: { status: 'up', httpStatus: 200 } }) + const service = new ServiceMonitorService(keycloak, undefined, undefined, undefined, undefined, sonarqube, undefined) + await service.refreshServiceHealth() + await service.refreshServiceHealth() + + expect(keycloak.check).toHaveBeenCalledTimes(2) + expect(sonarqube.check).toHaveBeenCalledTimes(2) + expect(sonarqube.check).toHaveBeenCalledWith('sonarqube') + }) +}) 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..da6f1641d8 --- /dev/null +++ b/apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts @@ -0,0 +1,85 @@ +import type { OnModuleInit } from '@nestjs/common' +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' + +const INTERVAL_MS = 5 * 60 * 1000 + +export interface ServiceHealth { + name: string + status: 'OK' | 'Dégradé' | 'En échec' | 'Inconnu' + interval: number + lastUpdateTimestamp: number + message: string + cause?: unknown +} + +@Injectable() +export class ServiceMonitorService implements OnModuleInit { + 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, + ) {} + + getServiceHealth(): Promise { + return this.collect() + } + + getCompleteServiceHealth(): Promise { + return this.collect() + } + + refreshServiceHealth(): Promise { + return this.collect() + } + + onModuleInit(): void { + void this.collect() + } + + private async collect(): Promise { + const { argocd, gitlab, harbor, nexus, sonarqube, vault } = this + const probes: Array<[string, string, (() => Promise) | undefined]> = [ + ['argocd', 'ArgoCD', argocd && (() => argocd.check())], + ['gitlab', 'Gitlab', gitlab && (() => gitlab.check())], + ['harbor', 'Harbor', harbor && (() => harbor.check())], + ['keycloak', 'Keycloak', () => this.keycloak.check()], + ['nexus', 'Nexus', nexus && (() => nexus.check())], + ['sonarqube', 'SonarQube', sonarqube && (() => sonarqube.check('sonarqube'))], + ['vault', 'Vault', vault && (() => vault.check())], + ] + + const settled = await Promise.allSettled(probes.map(([, , probe]) => probe?.())) + return settled.flatMap((result, i): ServiceHealth[] => { + const [, name] = probes[i] + const timestamp = Date.now() + if (result.status === 'rejected') { + const message = result.reason instanceof Error ? result.reason.message : String(result.reason) + return [{ name, status: 'En échec', interval: INTERVAL_MS, lastUpdateTimestamp: timestamp, message, cause: message }] + } + const value = result.value as Record + if (!value) return [] + const detail = value[Object.keys(value)[0]] + if (!detail) return [] + const up = detail.status === 'up' + return [{ + name, + status: up ? 'OK' : 'En échec', + interval: INTERVAL_MS, + lastUpdateTimestamp: timestamp, + message: up ? 'OK' : detail.message ?? 'Service en erreur', + cause: up ? undefined : detail.message ?? 'Service en erreur', + }] + }) + } +} 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