Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
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:^",
Comment thread
shikanime marked this conversation as resolved.
"@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 @@ -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'
Expand Down Expand Up @@ -55,6 +56,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
ProjectSecretsModule,
ProjectServicesModule,
RepositoryModule,
ServiceMonitorModule,
ScheduleModule.forRoot(),
StageModule,
SystemConfigModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { HarborConfig } from '../../config/harbor.config'
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<HarborConfig>({
url: harborUrl,
internalUrl: undefined,
admin: 'admin',
adminPassword: 'password',
})

const module = await Test.createTestingModule({
providers: [
RegistryHealthService,
{ provide: harborConfigFactory.KEY, useValue: config },
HealthIndicatorService,
],
}).compile()

service = module.get(RegistryHealthService)
})
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('reports up when every component is healthy', async () => {
server.use(http.get(healthUrl, () => HttpResponse.json({ status: 'healthy', components: [] })))

await expect(service.check()).resolves.toEqual({ harbor: { status: 'up' } })
})

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.check()).resolves.toEqual({ harbor: { status: 'down', message: 'Service en erreur' } })
})

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.check()).resolves.toEqual({ harbor: { status: 'up', message: 'Service dégradé' } })
})

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.check()).resolves.toEqual({ harbor: { status: 'down', message: 'Fatal Error' } })
})

it('reports down with the cause when the request fails', async () => {
server.use(http.get(healthUrl, () => HttpResponse.error()))

await expect(service.check()).resolves.toMatchObject({
harbor: { status: 'down', message: 'Erreur lors la requête', cause: expect.any(Error) },
})
})
})
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
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'

// /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 {
constructor(
Expand All @@ -13,17 +25,29 @@ export class RegistryHealthService {

async check() {
const indicator = this.healthIndicator.check(PLUGIN_NAME)
const headers: Record<string, string> = {}
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 })
if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status })
return indicator.down({ httpStatus: response.status })
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.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 })
}
}

private authHeaders(): Record<string, string> {
const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}`
return { Authorization: `Basic ${Buffer.from(credentials).toString('base64')}` }
}
}
Original file line number Diff line number Diff line change
@@ -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'
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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'
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<ServiceMonitorService>

beforeEach(async () => {
service = mock<ServiceMonitorService>()

module = await Test.createTestingModule({
controllers: [ServiceMonitorController],
providers: [
{ provide: ServiceMonitorService, useValue: service },
],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<ServiceMonitorController>(ServiceMonitorController)
})

it('should be defined', () => {
expect(controller).toBeDefined()
})

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.toEqual([{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0 }])
expect(service.getServiceHealth).toHaveBeenCalledTimes(1)
})

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.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, 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)

await expect(controller.refreshServiceHealth()).resolves.toEqual([{ name: 'keycloak', status: MonitorStatus.ERROR, message: 'down', interval: 1000, lastUpdateTimestamp: 0, cause: 'boom' }])
expect(service.refreshServiceHealth).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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) } : {}) }

Check notice on line 9 in apps/server-nestjs/src/modules/service-monitor/service-monitor.controller.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

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

Unexpected negated condition.
}

// MonitorInfos.cause is admin-only
function toPublicServiceHealth({ cause, ...health }: ServiceHealth): ServiceHealth {
return health
}

@Controller('api/v1')
export class ServiceMonitorController {
constructor(
@Inject(ServiceMonitorService) private readonly service: ServiceMonitorService,
) {}

@Get('health-services')
getServiceHealth() {
return this.service.getServiceHealth().then(health => health.map(toPublicServiceHealth))
}

@Get('complete-services')
@UseGuards(UserGuard)
@RequireAdminPermission('ListSystem')
getCompleteServiceHealth() {
return this.service.getServiceHealth().then(health => health.map(toServiceHealth))
}

@Get('refresh-services')
@UseGuards(UserGuard)
@RequireAdminPermission('ManageSystem')
refreshServiceHealth() {
return this.service.refreshServiceHealth().then(health => health.map(toServiceHealth))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { CacheModule } from '@nestjs/cache-manager'
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: [
CacheModule.register(),
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 {}
Loading
Loading