Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/nginx-strangler/conf.d/routing.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 0 additions & 7 deletions apps/server-nestjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -48,6 +49,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ProjectServicesModule,
RepositoryModule,
ScheduleModule.forRoot(),
ServiceMonitorModule,
SystemConfigModule,
SystemSettingsModule,
VersionModule,
Expand Down
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 nit: 7 positionnels undefined dans new ServiceMonitorService(...) (aussi L27, L37, L51) : un réordonnancement du constructeur passe inaperçu. Objet d'options ou builder de fixtures.


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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 risk: Drift refresh : le legacy met en cache lastStatus et Monitor.refresh() réarme un setInterval (packages/shared/src/utils/plugins.ts) ; ici les 3 endpoints sont identiques (= collect() live à chaque GET), interval: INTERVAL_MS cosmétique, onModuleInit jette le résultat. Cacher le dernier statut + scheduler, sinon retirer onModuleInit et l'interval factice.

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

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/service-monitor/service-monitor.service.ts#L62

Unexpected iterable of non-Promise (non-"Thenable") values passed to promise aggregator.
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',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 risk: cause absent sur la branche down (5xx / indicator.down) : le legacy pose cause sur les erreurs et /complete-services l'expose aux admins (contrat cause: z.any().optional()). Ajouter cause: detail.message sur le retour down.

cause: up ? undefined : detail.message ?? 'Service en erreur',
}]
})
}
}
21 changes: 0 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading