Repository navigation
refactor(service-monitor): migrate module from server #2488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ArgoCDHealthService>() | ||
| argocd.check.mockResolvedValue({ argocd: { status: 'up', httpStatus: 200 } }) | ||
| const keycloak = mockDeep<KeycloakHealthService>() | ||
| 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<KeycloakHealthService>() | ||
| 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<KeycloakHealthService>() | ||
| 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<KeycloakHealthService>() | ||
| keycloak.check.mockResolvedValue({ keycloak: { status: 'up', httpStatus: 200 } }) | ||
| const sonarqube = mockDeep<SonarqubeHealthService>() | ||
| 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') | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ServiceHealth[]> { | ||
| return this.collect() | ||
| } | ||
|
|
||
| getCompleteServiceHealth(): Promise<ServiceHealth[]> { | ||
| return this.collect() | ||
| } | ||
|
|
||
| refreshServiceHealth(): Promise<ServiceHealth[]> { | ||
| return this.collect() | ||
| } | ||
|
|
||
| onModuleInit(): void { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 risk: Drift refresh : le legacy met en cache |
||
| void this.collect() | ||
| } | ||
|
|
||
| private async collect(): Promise<ServiceHealth[]> { | ||
| const { argocd, gitlab, harbor, nexus, sonarqube, vault } = this | ||
| const probes: Array<[string, string, (() => Promise<unknown>) | 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?.())) | ||
|
Check failure on line 62 in apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts
|
||
| 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<string, { status?: string, message?: string }> | ||
| 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', | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 risk: « Dégradé » et « Inconnu » inatteignables alors que l'interface les déclare — les monitors legacy retournent WARNING (dégradation partielle gitlab/harbor/nexus/sonarqube) et UNKNOW (erreur réseau), et le front bannit sur ces deux statuts. Distinguer via httpStatus (5xx → « Dégradé », fetch KO → « Inconnu »). |
||
| interval: INTERVAL_MS, | ||
| lastUpdateTimestamp: timestamp, | ||
| message: up ? 'OK' : detail.message ?? 'Service en erreur', | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 risk: |
||
| cause: up ? undefined : detail.message ?? 'Service en erreur', | ||
| }] | ||
| }) | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 nit: 7 positionnels
undefineddansnew ServiceMonitorService(...)(aussi L27, L37, L51) : un réordonnancement du constructeur passe inaperçu. Objet d'options ou builder de fixtures.