Skip to content
Merged
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
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 @@ -22,6 +22,7 @@ import { RepositoryModule } from './modules/repository/repository.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { VersionModule } from './modules/version/version.module'
import { ZoneModule } from './modules/zone/zone.module'
import { getDotenvPaths } from './utils/dotenv.utils'

@Module({
Expand Down Expand Up @@ -52,6 +53,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
SystemConfigModule,
SystemSettingsModule,
VersionModule,
ZoneModule,
],
controllers: [],
providers: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { AdminRole } from './admin-role-queries.utils'
import type { AdminRoleService } from './admin-role.service'
import type { CreateAdminRoleBody, PatchAdminRolesBody } from './admin-role.utils'

export function makeAdminRole(overrides: Partial<AdminRole> = {}): AdminRole {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ConfigType } from '@nestjs/config'
import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter'
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { faker } from '@faker-js/faker'
import { EventEmitter2 } from '@nestjs/event-emitter'
import { Test } from '@nestjs/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -175,4 +176,26 @@ describe('appEventsService', () => {
data: expect.objectContaining({ args: payload }),
}))
})

it('emits and logs zone events with the event-log data shape and no project id', async () => {
const payload = { id: faker.string.uuid(), slug: faker.string.alpha({ length: 6, casing: 'lower' }) }
eventEmitter.emitAsync.mockResolvedValue([
{ vault: { status: 'KO', message: 'Vault unreachable', executionTime: 5, error: new Error('boom') } },
])

const results = await service.emitZoneEvent('zone.delete', payload, { action: 'Delete zone', userId: 'user-id', requestId: 'request-id' })

expect(eventEmitter.emitAsync).toHaveBeenCalledWith('zone.delete', payload)
expect(results.vault?.status).toEqual('KO')
expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({
action: 'Delete zone',
projectId: null,
data: expect.objectContaining({
args: payload,
failed: ['vault'],
results: { vault: expect.objectContaining({ status: expect.objectContaining({ result: 'KO' }) }) },
messageResume: expect.stringContaining('vault'),
}),
}))
})
})
20 changes: 19 additions & 1 deletion apps/server-nestjs/src/modules/events/app-events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { formatEventLogData, isPluginResults } from './app-events.utils'
export type ProjectEventName = 'project.upsert' | 'project.delete'
export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete'
export type RepositoryEventName = 'repository.sync'
export type ZoneEventName = 'zone.upsert' | 'zone.delete'

export interface ZoneEventPayload {
id: string
slug: string
}

export interface ProjectMemberEventPayload {
projectId: string
Expand All @@ -37,6 +43,7 @@ export type EventLogAction
| 'Create Environment' | 'Update Environment' | 'Delete Environment'
| 'Create Repository' | 'Update Repository' | 'Delete Repository' | 'Sync Repository'
| 'Add Project Member' | 'Update Project Member' | 'Remove Project Member'
| 'Create zone' | 'Update zone' | 'Delete zone'

export interface EventContext {
/** Action label persisted in the admin log. */
Expand Down Expand Up @@ -109,10 +116,21 @@ export class AppEventsService {
return this.emitAndLog(event, payload, payload.projectId, context)
}

// Emits a zone event. Zones have no project row: the log carries no project id
// and the caller awaits the merged results to answer 422 on failure (legacy hooks
// behavior on `POST`/`PUT`/`DELETE /zones`).
async emitZoneEvent(
event: ZoneEventName,
zone: ZoneEventPayload,
context: EventContext,
Comment thread
shikanime marked this conversation as resolved.
): Promise<PluginResults> {
return this.emitAndLog(event, zone, null, context)
}

private async emitAndLog(
event: string,
payload: unknown,
projectId: string,
projectId: string | null,
context: EventContext,
): Promise<PluginResults> {
const start = process.hrtime.bigint()
Expand Down
29 changes: 15 additions & 14 deletions apps/server-nestjs/src/modules/vault/vault.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ConfigType } from '@nestjs/config'
import type { ZoneEventPayload } from '../events/app-events.service'
import type { RequiredPluginResult } from '../plugin/plugin.utils'
import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service'
import { Inject, Injectable, Logger } from '@nestjs/common'
Expand Down Expand Up @@ -85,31 +86,31 @@ export class VaultService {
}

@OnEvent('zone.upsert')
Comment thread
shikanime marked this conversation as resolved.
async handleUpsertZone(zone: ZoneWithDetails): Promise<RequiredPluginResult<'vault'>> {
return capturePluginResult('vault', () => this.syncZone(zone))
async handleUpsertZone(payload: ZoneEventPayload): Promise<RequiredPluginResult<'vault'>> {
return capturePluginResult('vault', () => this.syncZone(payload.slug))
}

@StartActiveSpan()
private async syncZone(zone: ZoneWithDetails) {
private async syncZone(zoneSlug: string) {
const span = trace.getActiveSpan()
span?.setAttribute('zone.slug', zone.slug)
this.logger.log(`Handling a zone upsert event for ${zone.slug}`)
await this.ensureZone(zone)
this.logger.log(`Vault zone sync completed for ${zone.slug}`)
span?.setAttribute('zone.slug', zoneSlug)
this.logger.log(`Handling a zone upsert event for ${zoneSlug}`)
await this.upsertZone(zoneSlug)
this.logger.log(`Vault zone sync completed for ${zoneSlug}`)
}

@OnEvent('zone.delete')
async handleDeleteZone(zone: ZoneWithDetails): Promise<RequiredPluginResult<'vault'>> {
return capturePluginResult('vault', () => this.cleanupZone(zone))
async handleDeleteZone(payload: ZoneEventPayload): Promise<RequiredPluginResult<'vault'>> {
return capturePluginResult('vault', () => this.cleanupZone(payload.slug))
}

@StartActiveSpan()
private async cleanupZone(zone: ZoneWithDetails) {
private async cleanupZone(zoneSlug: string) {
const span = trace.getActiveSpan()
span?.setAttribute('zone.slug', zone.slug)
this.logger.log(`Handling a zone delete event for ${zone.slug}`)
await this.deleteZone(zone.slug)
this.logger.log(`Vault zone cleanup completed for ${zone.slug}`)
span?.setAttribute('zone.slug', zoneSlug)
this.logger.log(`Handling a zone delete event for ${zoneSlug}`)
await this.deleteZone(zoneSlug)
this.logger.log(`Vault zone cleanup completed for ${zoneSlug}`)
}

// @Cron(CronExpression.EVERY_HOUR)
Expand Down
58 changes: 58 additions & 0 deletions apps/server-nestjs/src/modules/zone/zone-queries.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Cluster, Prisma } from '@prisma/client'

export const zoneSelect = {
id: true,
slug: true,
label: true,
argocdUrl: true,
description: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.ZoneSelect

export type Zone = Prisma.ZoneGetPayload<{
select: typeof zoneSelect
}>

export function getZoneById(tx: Prisma.TransactionClient, id: Zone['id']) {
return tx.zone.findUnique({ where: { id }, select: zoneSelect })
}

export function listZones(tx: Prisma.TransactionClient) {
return tx.zone.findMany({ select: zoneSelect })
}

export function createZone(
tx: Prisma.TransactionClient,
data: Pick<Zone, 'slug' | 'label' | 'argocdUrl' | 'description'>,
) {
return tx.zone.create({ data, select: zoneSelect })
}

export function updateZone(
tx: Prisma.TransactionClient,
zoneId: Zone['id'],
data: Pick<Zone, 'label' | 'argocdUrl' | 'description'>,
) {
return tx.zone.update({ where: { id: zoneId }, data, select: zoneSelect })
}

export function linkZoneToClusters(tx: Prisma.TransactionClient, zoneId: Zone['id'], clusterIds: Array<Cluster['id']>) {
return tx.zone.update({
where: { id: zoneId },
data: { clusters: { connect: clusterIds.map(id => ({ id })) } },
select: zoneSelect,
})
}

export async function createZoneWithClusters(
tx: Prisma.TransactionClient,
data: Pick<Zone, 'slug' | 'label' | 'argocdUrl' | 'description'>,
clusterIds?: Array<Cluster['id']>,
) {
const zone = await createZone(tx, data)
if (clusterIds?.length) {
await linkZoneToClusters(tx, zone.id, clusterIds)
}
return zone
}
15 changes: 15 additions & 0 deletions apps/server-nestjs/src/modules/zone/zone-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Zone } from './zone-queries.utils'
import { faker } from '@faker-js/faker'

export function makeZone(overrides: Partial<Zone> = {}): Zone {
return {
id: faker.string.uuid(),
slug: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(),
label: faker.word.sample(10),
argocdUrl: `https://argocd.example.com/${faker.helpers.slugify(faker.word.sample(5))}`,
description: faker.lorem.sentence(),
createdAt: faker.date.past(),
updatedAt: faker.date.past(),
...overrides,
} satisfies Zone
}
65 changes: 65 additions & 0 deletions apps/server-nestjs/src/modules/zone/zone.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { NestFastifyApplication } from '@nestjs/platform-fastify'
import type { TestingModule } from '@nestjs/testing'
import { FastifyAdapter } from '@nestjs/platform-fastify'
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 { makeZone } from './zone-testing.utils'
import { ZoneController } from './zone.controller'
import { ZoneService } from './zone.service'
import { toZone } from './zone.utils'

describe('zoneController', () => {
let module: TestingModule
let controller: ZoneController
let service: MockProxy<ZoneService>

const zone = makeZone({ slug: 'paris', label: 'Paris' })

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

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

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

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

it('serves GET list without authentication, matching the legacy public route', async () => {
const app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
await app.init()
service.list.mockResolvedValue([zone])

const response = await app.inject({
method: 'GET',
url: '/api/v1/zones',
})

expect(response.statusCode).toEqual(200)
expect(response.json()).toEqual([toZone(zone)])
await app.close()
})

it('rejects non-UUID zone params with 400 before reaching the service', async () => {
const app = module.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
await app.init()

for (const method of ['PUT', 'DELETE'] as const) {
const response = await app.inject({ method, url: '/api/v1/zones/not-an-uuid' })
expect(response.statusCode).toEqual(400)
}
await app.close()
})
})
61 changes: 61 additions & 0 deletions apps/server-nestjs/src/modules/zone/zone.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared'
import type { FastifyRequest } from 'fastify'
import type { UserContext } from '../infrastructure/auth/auth-user.decorator'
import { zoneContract } from '@cpn-console/shared'
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common'
import { AuthUser } from '../infrastructure/auth/auth-user.decorator'
import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator'
import { RequireUserType } from '../infrastructure/permission/user/user-type.decorator'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe'
import { ZoneService } from './zone.service'
import { toZone, toZones } from './zone.utils'

@Controller('api/v1/zones')
export class ZoneController {
constructor(@Inject(ZoneService) private readonly zoneService: ZoneService) {}

@Get()
async list(): Promise<Zone[]> {
return toZones(await this.zoneService.list())
}

@Post()
@HttpCode(HttpStatus.CREATED)
@UseGuards(UserGuard)
@RequireUserType('human')
@RequireAdminPermission('ManageZones')
async create(
@Body(new ZodValidationPipe(zoneContract.createZone.body)) body: CreateZoneBody,
@AuthUser() user: UserContext,
@Req() request: FastifyRequest,
): Promise<Zone> {
return toZone(await this.zoneService.create(body, user.userId, request.id))
}

@Put(':zoneId')
Comment thread
shikanime marked this conversation as resolved.
@UseGuards(UserGuard)
@RequireUserType('human')
@RequireAdminPermission('ManageZones')
async update(
@Param('zoneId', ParseUUIDPipe) zoneId: string,
@Body(new ZodValidationPipe(zoneContract.updateZone.body)) body: UpdateZoneBody,
@AuthUser() user: UserContext,
@Req() request: FastifyRequest,
): Promise<Zone> {
return toZone(await this.zoneService.update(zoneId, body, user.userId, request.id))
}

@Delete(':zoneId')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(UserGuard)
@RequireUserType('human')
@RequireAdminPermission('ManageZones')
async delete(
@Param('zoneId', ParseUUIDPipe) zoneId: string,
@AuthUser() user: UserContext,
@Req() request: FastifyRequest,
): Promise<void> {
return this.zoneService.delete(zoneId, user.userId, request.id)
}
}
16 changes: 16 additions & 0 deletions apps/server-nestjs/src/modules/zone/zone.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common'
import { AppEventsModule } from '../events/app-events.module'
import { InfrastructureModule } from '../infrastructure/infrastructure.module'
import { ZoneController } from './zone.controller'
import { ZoneService } from './zone.service'

@Module({
imports: [
AppEventsModule,
InfrastructureModule,
],
controllers: [ZoneController],
providers: [ZoneService],
exports: [ZoneService],
})
export class ZoneModule {}
Loading
Loading