From bb42249e80f976f879bab4b109b2a919f42bca93 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:04:28 +0200 Subject: [PATCH 01/22] refactor(cluster): migrate module from server Signed-off-by: William Phetsinorath Change-Id: Ib545fd310384d7847a47cd007d36a8526a6a6964 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I603381219713d5fcda1e1e6bf9b7a7496a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/main.module.ts | 2 + .../modules/cluster/cluster-queries.utils.ts | 221 ++++++++++++++++ .../modules/cluster/cluster-testing.utils.ts | 129 +++++++++ .../src/modules/cluster/cluster.controller.ts | 86 ++++++ .../src/modules/cluster/cluster.module.ts | 12 + .../modules/cluster/cluster.service.spec.ts | 234 ++++++++++++++++ .../src/modules/cluster/cluster.service.ts | 249 ++++++++++++++++++ 7 files changed, 933 insertions(+) create mode 100644 apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.controller.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.module.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.service.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0fdc0dce02..76ecd8753d 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -6,6 +6,7 @@ import { baseConfigFactory } from './config/base.config' import { AdminRoleModule } from './modules/admin-role/admin-role.module' import { AdminTokenModule } from './modules/admin-token/admin-token.module' import { AuthModule } from './modules/auth/auth.module' +import { ClusterModule } from './modules/cluster/cluster.module' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -40,6 +41,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' AdminRoleModule, AdminTokenModule, AuthModule, + ClusterModule, DeploymentModule, EnvironmentModule, HealthzModule, diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts new file mode 100644 index 0000000000..ecb0e85118 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -0,0 +1,221 @@ +import type { Cluster, Kubeconfig, Prisma, Project, Stage, Zone } from '@prisma/client' +import type { PrismaService } from '../infrastructure/database/prisma.service' + +// ── selects ─────────────────────────────────────────────────────────────────────────── +export const clusterListSelect = { + id: true, + label: true, + privacy: true, + secretName: true, + clusterResources: true, + kubeConfigId: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + createdAt: true, + updatedAt: true, + stages: true, +} satisfies Prisma.ClusterSelect +export type ClusterListRecord = Prisma.ClusterGetPayload<{ select: typeof clusterListSelect }> + +export const clusterDetailsSelect = { + id: true, + label: true, + privacy: true, + secretName: true, + clusterResources: true, + kubeConfigId: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + createdAt: true, + updatedAt: true, + projects: { select: { id: true } }, + kubeconfig: true, + stages: true, +} satisfies Prisma.ClusterSelect +export type ClusterDetailsRecord = Prisma.ClusterGetPayload<{ select: typeof clusterDetailsSelect }> + +export const clusterEnvironmentsSelect = { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + projectId: true, + autosync: true, + clusterId: true, + stageId: true, + createdAt: true, + updatedAt: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, +} satisfies Prisma.EnvironmentSelect +export type ClusterEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof clusterEnvironmentsSelect }> + +// ── query: getClusterById ─────────────────────────────────────────────────────────── +export function getClusterById(prisma: PrismaService, id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +// ── query: getClusterEnvironments ──────────────────────────────────────────────────── +export function getClusterEnvironments(prisma: PrismaService, clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: clusterEnvironmentsSelect, + }) +} + +// ── query: getClusterDetails ───────────────────────────────────────────────────────── +export function getClusterDetails(prisma: PrismaService, id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: clusterDetailsSelect, + }) +} + +// ── query: getClusterByLabel ────────────────────────────────────────────────────────── +export function getClusterByLabel(prisma: PrismaService, label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +// ── query: listClusters ────────────────────────────────────────────────────────────── +export function listClusters(prisma: PrismaService, where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: clusterListSelect, + }) +} + +// ── query: getProjectsByClusterId ───────────────────────────────────────────────────── +export async function getProjectsByClusterId(prisma: PrismaService, id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +// ── query: listStagesByClusterId ────────────────────────────────────────────────────── +export async function listStagesByClusterId(prisma: PrismaService, id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +// ── query: createCluster ───────────────────────────────────────────────────────────── +export function createCluster( + prisma: PrismaService, + data: Omit, + kubeconfig: Pick, + zoneId: string, +) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { connect: { id: zoneId } }, + }, + }) +} + +// ── query: updateCluster ───────────────────────────────────────────────────────────── +export function updateCluster( + prisma: PrismaService, + id: Cluster['id'], + data: Partial>, + kubeconfig: Pick, +) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +// ── query: linkClusterToProjects ────────────────────────────────────────────────────── +export function linkClusterToProjects(prisma: PrismaService, id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { connect: projectIds.map(projectId => ({ id: projectId })) }, + }, + }) +} + +// ── query: linkClusterToStages ──────────────────────────────────────────────────────── +export function linkClusterToStages(prisma: PrismaService, id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { connect: stageIds.map(stageId => ({ id: stageId })) }, + }, + }) +} + +// ── query: removeClusterFromProject ─────────────────────────────────────────────────── +export function removeClusterFromProject(prisma: PrismaService, id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { disconnect: { id: projectId } }, + }, + }) +} + +// ── query: removeClusterFromStage ───────────────────────────────────────────────────── +export function removeClusterFromStage(prisma: PrismaService, id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { disconnect: { id: stageId } }, + }, + }) +} + +// ── query: deleteCluster ───────────────────────────────────────────────────────────── +export function deleteCluster(prisma: PrismaService, id: Cluster['id']) { + return prisma.cluster.delete({ where: { id } }) +} + +// ── query: linkZoneToClusters ──────────────────────────────────────────────────────── +export function linkZoneToClusters(prisma: PrismaService, zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { id: zoneId }, + data: { + clusters: { connect: clusterIds.map(clusterId => ({ id: clusterId })) }, + }, + }) +} + +// ── query: getClusterUsage ─────────────────────────────────────────────────────────── +export async function getClusterUsage(prisma: PrismaService, clusterId: Cluster['id']) { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { memory: true, cpu: true, gpu: true }, + where: { clusterId }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} diff --git a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts new file mode 100644 index 0000000000..b1b502d634 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -0,0 +1,129 @@ +import type { Cluster, Environment, Kubeconfig, ProjectMembers, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), + privacy: faker.helpers.arrayElement(['public', 'dedicated'] as const), + secretName: faker.string.uuid(), + clusterResources: faker.datatype.boolean(), + kubeConfigId: faker.string.uuid(), + infos: faker.lorem.sentence(), + cpu: faker.number.int({ min: 0, max: 64 }), + gpu: faker.number.int({ min: 0, max: 8 }), + memory: faker.number.int({ min: 0, max: 512 }), + zoneId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Cluster +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(), + ...overrides, + } satisfies Stage +} + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + adminRoleIds: [], + type: 'human', + ...overrides, + } satisfies User +} + +export function makeProjectMember(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} + +export function makeClusterListRecord(overrides: Partial = {}): ClusterListRecord { + return { + ...makeCluster(), + stages: [makeStage()], + ...overrides, + } satisfies ClusterListRecord +} + +export function makeClusterDetailsRecord(overrides: Partial = {}): ClusterDetailsRecord { + return { + ...makeCluster(), + projects: [{ id: faker.string.uuid() }], + stages: [makeStage()], + kubeconfig: makeKubeconfig(), + ...overrides, + } satisfies ClusterDetailsRecord +} + +export function makeClusterEnvironmentsRecord(overrides: Partial = {}): ClusterEnvironmentsRecord { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 11), + cpu: faker.number.int({ min: 0, max: 16 }), + gpu: faker.number.int({ min: 0, max: 4 }), + memory: faker.number.int({ min: 0, max: 64 }), + projectId: faker.string.uuid(), + autosync: true, + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + project: { + slug: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(), + name: faker.company.name(), + owner: makeUser(), + members: [makeProjectMember()], + }, + ...overrides, + } satisfies ClusterEnvironmentsRecord +} + +export function makeKubeconfig(overrides: Partial = {}): Kubeconfig { + return { + id: faker.string.uuid(), + user: { + username: faker.internet.userName(), + token: faker.string.alphanumeric(20), + }, + cluster: { + server: faker.internet.url(), + tlsServerName: faker.internet.domainName(), + }, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Kubeconfig +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 11), + projectId: faker.string.uuid(), + memory: faker.number.int({ min: 0, max: 64 }), + cpu: faker.number.int({ min: 0, max: 16 }), + gpu: faker.number.int({ min: 0, max: 4 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } satisfies Environment +} diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts new file mode 100644 index 0000000000..597e693d75 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -0,0 +1,86 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { clusterContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ClusterService } from './cluster.service' + +type ClusterList = ClientInferResponseBody +type ClusterDetails = ClientInferResponseBody +type ClusterUsage = ClientInferResponseBody +type CreateClusterBody = typeof clusterContract.createCluster.body._type +type UpdateClusterBody = typeof clusterContract.updateCluster.body._type + +@Controller('api/v1/clusters') +@UseGuards(UserGuard) +export class ClusterController { + constructor(@Inject(ClusterService) private readonly clusterService: ClusterService) {} + + @Get('') + @RequireAdminPermission('ListClusters') + list(): Promise { + return this.clusterService.listClusters() + } + + @Get(':clusterId') + @RequireAdminPermission('ListClusters') + getDetails(@Param('clusterId') clusterId: string): Promise { + return this.clusterService.getClusterDetails(clusterId) + } + + @Get(':clusterId/usage') + @RequireAdminPermission('ListClusters') + getUsage(@Param('clusterId') clusterId: string): Promise { + return this.clusterService.getClusterUsage(clusterId) + } + + @Get(':clusterId/environments') + @RequireAdminPermission('ListClusters') + getEnvironments(@Param('clusterId') clusterId: string): Promise { + return this.clusterService.getClusterAssociatedEnvironments(clusterId) + } + + @Post('') + @RequireAdminPermission('ManageClusters') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(clusterContract.createCluster.body)) data: CreateClusterBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.clusterService.createCluster(data, user.userId, request.id) + } + + @Put(':clusterId') + @RequireAdminPermission('ManageClusters') + @HttpCode(HttpStatus.OK) + update( + @Param('clusterId') clusterId: string, + @Body(new ZodValidationPipe(clusterContract.updateCluster.body)) data: UpdateClusterBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.clusterService.updateCluster(data, clusterId, user.userId, request.id) + } + + @Delete(':clusterId') + @RequireAdminPermission('ManageClusters') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('clusterId') clusterId: string, + @Query(new ZodValidationPipe(clusterContract.deleteCluster.query)) { force }: { force?: boolean }, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.clusterService.deleteCluster({ + clusterId, + userId: user.userId, + requestId: request.id, + force, + }) + } +} diff --git a/apps/server-nestjs/src/modules/cluster/cluster.module.ts b/apps/server-nestjs/src/modules/cluster/cluster.module.ts new file mode 100644 index 0000000000..a63c33650a --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ClusterController } from './cluster.controller' +import { ClusterService } from './cluster.service' + +@Module({ + imports: [DatabaseModule], + controllers: [ClusterController], + providers: [ClusterService], + exports: [ClusterService], +}) +export class ClusterModule {} diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts new file mode 100644 index 0000000000..f59699ca16 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -0,0 +1,234 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { + makeCluster, + makeClusterDetailsRecord, + makeClusterEnvironmentsRecord, + makeClusterListRecord, + makeEnvironment, +} from './cluster-testing.utils' +import { ClusterService } from './cluster.service' + +describe('ClusterService', () => { + let service: ClusterService + let prisma: DeepMockProxy + let logs: DeepMockProxy + let events: DeepMockProxy + let baseConfig: DeepMockProxy> + + beforeEach(async () => { + prisma = mockDeep() + logs = mockDeep() + events = mockDeep() + baseConfig = mockDeep>() + + const moduleRef = await Test.createTestingModule({ + providers: [ + ClusterService, + { provide: PrismaService, useValue: prisma }, + { provide: LogService, useValue: logs }, + { provide: EventEmitter2, useValue: events }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + ], + }).compile() + + service = moduleRef.get(ClusterService) + }) + + it('lists clusters with stageIds and normalized infos', async () => { + const record = makeClusterListRecord({ infos: null }) + prisma.cluster.findMany.mockResolvedValue([record]) + + const result = await service.listClusters() + + expect(result).toEqual([{ + id: record.id, + label: record.label, + infos: '', + clusterResources: record.clusterResources, + privacy: record.privacy, + zoneId: record.zoneId, + cpu: record.cpu, + gpu: record.gpu, + memory: record.memory, + stageIds: [record.stages[0].id], + }]) + }) + + it('passes the authorized user filter when listing clusters', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + + const userId = faker.string.uuid() + await service.listClusters(userId) + + expect(prisma.cluster.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { OR: expect.any(Array) }, + })) + }) + + it('maps cluster details to the contract shape', async () => { + const record = makeClusterDetailsRecord({ infos: null }) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(record) + + const result = await service.getClusterDetails(record.id) + + expect(result).toEqual(expect.objectContaining({ + id: record.id, + infos: '', + projectIds: [record.projects[0].id], + stageIds: [record.stages[0].id], + kubeconfig: { cluster: record.kubeconfig.cluster, user: record.kubeconfig.user }, + })) + }) + + it('returns cluster usage from the aggregate', async () => { + const usage = { cpu: 1, gpu: 0, memory: 8 } + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 8 }, + _count: { _all: 1 }, + _avg: { cpu: null, gpu: null, memory: null }, + _min: { cpu: null, gpu: null, memory: null }, + _max: { cpu: null, gpu: null, memory: null }, + }) + + const result = await service.getClusterUsage(faker.string.uuid()) + + expect(result).toEqual(usage) + }) + + it('creates a cluster, links projects and stages, and emits the hook', async () => { + const record = makeClusterListRecord() + const cluster = makeCluster() + const details = makeClusterDetailsRecord() + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.create.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(details) + + const result = await service.createCluster( + { + label: record.label, + infos: record.infos ?? '', + clusterResources: record.clusterResources, + privacy: record.privacy, + zoneId: record.zoneId, + cpu: record.cpu, + gpu: record.gpu, + memory: record.memory, + projectIds: ['project-1'], + stageIds: ['stage-1'], + kubeconfig: { cluster: { tlsServerName: 'example.com' }, user: {} }, + }, + faker.string.uuid(), + faker.string.uuid(), + ) + + expect(result.id).toEqual(details.id) + expect(prisma.cluster.create).toHaveBeenCalled() + expect(prisma.cluster.update).toHaveBeenCalled() + expect(events.emitAsync).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: cluster.id })) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Create Cluster' })) + }) + + it('rejects cluster creation when the label is already taken', async () => { + prisma.cluster.findUnique.mockResolvedValue(makeCluster()) + + await expect( + service.createCluster( + { + label: 'taken', + infos: '', + clusterResources: true, + privacy: 'public', + zoneId: faker.string.uuid(), + cpu: 1, + gpu: 0, + memory: 1, + stageIds: [], + kubeconfig: { cluster: { tlsServerName: 'example.com' }, user: {} }, + }, + faker.string.uuid(), + faker.string.uuid(), + ), + ).rejects.toThrow('Ce label existe déjà') + }) + + it('updates cluster fields and emits the hook', async () => { + const record = makeClusterDetailsRecord() + prisma.cluster.findUnique.mockResolvedValue(record) + prisma.cluster.update.mockResolvedValue(record) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(record) + + const result = await service.updateCluster( + { label: 'new-label' }, + record.id, + faker.string.uuid(), + faker.string.uuid(), + ) + + expect(result.id).toEqual(record.id) + expect(prisma.cluster.update).toHaveBeenCalled() + expect(events.emitAsync).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id })) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Update Cluster' })) + }) + + it('rejects updating a missing cluster', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + + await expect( + service.updateCluster({ label: 'new' }, faker.string.uuid(), faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow('Cluster not found') + }) + + it('deletes a cluster when no environments are deployed', async () => { + const record = makeClusterListRecord() + prisma.environment.findFirst.mockResolvedValue(null) + prisma.cluster.delete.mockResolvedValue(record) + + const message = await service.deleteCluster({ + clusterId: record.id, + userId: faker.string.uuid(), + requestId: faker.string.uuid(), + }) + + expect(message).toBeNull() + expect(prisma.cluster.delete).toHaveBeenCalledWith({ where: { id: record.id } }) + expect(events.emitAsync).toHaveBeenCalledWith('cluster.delete', expect.objectContaining({ clusterId: record.id })) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Delete Cluster' })) + }) + + it('rejects cluster deletion when environments are deployed', async () => { + prisma.environment.findFirst.mockResolvedValue(makeEnvironment()) + + await expect( + service.deleteCluster({ + clusterId: faker.string.uuid(), + userId: faker.string.uuid(), + requestId: faker.string.uuid(), + }), + ).rejects.toThrow('Impossible de supprimer le cluster') + }) + + it('maps cluster environments for the contract response', async () => { + const envs = [makeClusterEnvironmentsRecord(), makeClusterEnvironmentsRecord()] + prisma.environment.findMany.mockResolvedValue(envs) + + const result = await service.getClusterAssociatedEnvironments(faker.string.uuid()) + + expect(result).toEqual(envs.map(env => ({ + project: env.project.name, + name: env.name, + owner: env.project.owner.email, + cpu: env.cpu, + gpu: env.gpu, + memory: env.memory, + }))) + }) +}) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts new file mode 100644 index 0000000000..8e5a729547 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -0,0 +1,249 @@ +import type { + CleanedCluster, + ClusterAssociatedEnvironments, + clusterContract, + ClusterDetails, + CreateClusterBody, + UpdateClusterBody, +} from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' +import type { Cluster, Prisma, Project, User } from '@prisma/client' +import type { ClientInferResponseBody } from '@ts-rest/core' +import { + ClusterPrivacySchema, + KubeconfigSchema, +} from '@cpn-console/shared' +import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getClusterUsage, + getProjectsByClusterId, + linkClusterToProjects, + linkClusterToStages, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from './cluster-queries.utils' + +type ClusterUsage = ClientInferResponseBody + +const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public +const CLUSTER_DEDICATED = ClusterPrivacySchema.enum.dedicated + +@Injectable() +export class ClusterService { + private readonly logger = new Logger(ClusterService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + ) {} + + async listClusters(userId?: User['id']): Promise { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + { privacy: CLUSTER_PUBLIC }, + { projects: { some: { members: { some: { userId } } } } }, + { projects: { some: { ownerId: userId } } }, + { environments: { some: { project: { members: { some: { userId } } } } } }, + ], + } + : {} + const clusters = await listClustersQuery(this.prisma, where) + return clusters.map(({ stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster }) => ({ + ...cluster, + infos: infos ?? '', + stageIds: stages.map(({ id }) => id), + })) + } + + async getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, secretName, kubeConfigId, createdAt, updatedAt, ...details } = await getClusterDetailsQuery(this.prisma, clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: KubeconfigSchema.shape.cluster.passthrough().parse(kubeconfig.cluster), + user: KubeconfigSchema.shape.user.passthrough().parse(kubeconfig.user), + }, + } + } + + async getClusterUsage(clusterId: string): Promise { + return getClusterUsage(this.prisma, clusterId) + } + + async getClusterAssociatedEnvironments(clusterId: string): Promise { + const clusterEnvironments = await getClusterEnvironments(this.prisma, clusterId) + return clusterEnvironments.map(environment => ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project?.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + })) + } + + async createCluster( + data: CreateClusterBody, + userId: User['id'], + requestId: string, + ): Promise { + const isLabelTaken = await getClusterByLabel(this.prisma, data.label) + if (isLabelTaken) throw new ConflictException('Ce label existe déjà pour un autre cluster') + + const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data + + const clusterCreated = await createClusterQuery(this.prisma, clusterData, kubeconfig, zoneId) + + if (data.privacy !== CLUSTER_PUBLIC && projectIds?.length) { + await linkClusterToProjects(this.prisma, clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(this.prisma, clusterCreated.id, stageIds) + } + + await this.upsertClusterHook(clusterCreated.id, zoneId) + await this.logs.addLog({ + action: 'Create Cluster', + data: { clusterId: clusterCreated.id, zoneId }, + userId, + requestId, + }) + + return this.getClusterDetails(clusterCreated.id) + } + + async updateCluster( + data: UpdateClusterBody, + clusterId: string, + userId: User['id'], + requestId: string, + ): Promise { + if (data?.privacy === CLUSTER_PUBLIC) delete data.projectIds + + const dbCluster = await getClusterById(this.prisma, clusterId) + if (!dbCluster) throw new NotFoundException('Cluster not found') + + const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data + + const clusterUpdated = await updateClusterQuery(this.prisma, clusterId, clusterData, + // @ts-ignore + kubeconfig) + + if (zoneId) { + await linkZoneToClusters(this.prisma, zoneId, [clusterId]) + } + + const dbProjects = await getProjectsByClusterId(this.prisma, clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === CLUSTER_PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } else if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { + await linkClusterToProjects(this.prisma, clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === CLUSTER_PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(this.prisma, clusterUpdated.id, projectId) + } + + if (stageIds) { + await linkClusterToStages(this.prisma, clusterId, stageIds) + + const dbStages = await listStagesByClusterId(this.prisma, clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(this.prisma, clusterUpdated.id, stage.id) + } + } + } + } + + await this.upsertClusterHook(clusterId, dbCluster.zoneId) + await this.logs.addLog({ + action: 'Update Cluster', + data: { clusterId, zoneId: dbCluster.zoneId }, + userId, + requestId, + }) + + return this.getClusterDetails(clusterId) + } + + async deleteCluster({ + clusterId, + userId, + requestId, + force, + }: { + clusterId: string + userId?: string + requestId: string + force?: boolean + }): Promise { + let message: string | null = null + if (force) { + const envs = await this.prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await this.prisma.environment.findFirst({ where: { clusterId } }) + if (environment) throw new BadRequestException('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + await this.deleteClusterHook(clusterId) + await this.logs.addLog({ + action: 'Delete Cluster', + data: { clusterId }, + userId, + requestId, + }) + + await deleteClusterQuery(this.prisma, clusterId) + return message + } + + // ── Cluster hook helpers ──────────────────────────────────────────────────────── + + private async upsertClusterHook(clusterId: string, zoneId: Cluster['zoneId']): Promise { + try { + await this.eventEmitter.emitAsync('cluster.upsert', { clusterId, zoneId }) + } catch (error) { + this.logger.error(`cluster.upsert hook failed (clusterId=${clusterId})`, error instanceof Error ? error.stack : String(error)) + } + } + + private async deleteClusterHook(clusterId: string): Promise { + try { + await this.eventEmitter.emitAsync('cluster.delete', { clusterId }) + } catch (error) { + this.logger.error(`cluster.delete hook failed (clusterId=${clusterId})`, error instanceof Error ? error.stack : String(error)) + } + } +} From 29e1757487f0d2aafa3b8a8737b0b0d0a0fbee3a Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:49:14 +0200 Subject: [PATCH 02/22] fix(server-nestjs): register AdminTokenModule for downstream stacks rebases cleanly. Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Id763439e79b52a6af29fdef6bf8c1d826a6a6964 From 8099274ae5f454f04d9bdcc57251f914bc643ef1 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:20:16 +0200 Subject: [PATCH 03/22] refactor(cluster): reuse shared cluster body type aliases in controller Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/modules/cluster/cluster.controller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index 597e693d75..9d90055f24 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -1,7 +1,7 @@ import type { ClientInferResponseBody } from '@ts-rest/core' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' -import { clusterContract } from '@cpn-console/shared' +import { type CreateClusterBody, clusterContract, type UpdateClusterBody } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' @@ -12,8 +12,6 @@ import { ClusterService } from './cluster.service' type ClusterList = ClientInferResponseBody type ClusterDetails = ClientInferResponseBody type ClusterUsage = ClientInferResponseBody -type CreateClusterBody = typeof clusterContract.createCluster.body._type -type UpdateClusterBody = typeof clusterContract.updateCluster.body._type @Controller('api/v1/clusters') @UseGuards(UserGuard) From 381e4df7a266fff5b01c8b1dc5c11512266b24d7 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:29:20 +0200 Subject: [PATCH 04/22] fix(server-nestjs): drop AdminTokenModule registration, owned by stacked admin-token PR Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ib00d7b390b49a646f18899caf9d879fb6a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/main.module.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 76ecd8753d..defea2f3c1 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,7 +4,6 @@ import { ScheduleModule } from '@nestjs/schedule' import { TerminusModule } from '@nestjs/terminus' import { baseConfigFactory } from './config/base.config' import { AdminRoleModule } from './modules/admin-role/admin-role.module' -import { AdminTokenModule } from './modules/admin-token/admin-token.module' import { AuthModule } from './modules/auth/auth.module' import { ClusterModule } from './modules/cluster/cluster.module' import { DeploymentModule } from './modules/deployment/deployment.module' @@ -39,7 +38,6 @@ import { getDotenvPaths } from './utils/dotenv.utils' }), TerminusModule.forRoot(), AdminRoleModule, - AdminTokenModule, AuthModule, ClusterModule, DeploymentModule, From 2287cf542b216841df3b8101e0ca1d4a3199a016 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:06:18 +0200 Subject: [PATCH 05/22] refactor(cluster): upstream named body schemas and apply review fixes - name CreateClusterBodySchema/UpdateClusterBodySchema in shared, derive type aliases from them, validate at controller pipe - propagate cluster.upsert hook failure as 422 with regression test - dedupe test factories to canonical modules, drop divider comments Signed-off-by: William Phetsinorath Change-Id: Ia5abe56ef859df38cc46c7491b8c07096a6a6964 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I74a8bf02a144ac92603ed7b89a4c395d6a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster-queries.utils.ts | 101 ++++++++------- .../modules/cluster/cluster-testing.utils.ts | 49 +------- .../src/modules/cluster/cluster.controller.ts | 13 +- .../modules/cluster/cluster.service.spec.ts | 22 +++- .../src/modules/cluster/cluster.service.ts | 119 +++++++++--------- packages/shared/src/contracts/cluster.ts | 13 +- packages/shared/src/schemas/cluster.ts | 13 ++ 7 files changed, 162 insertions(+), 168 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index ecb0e85118..2958bfebd2 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -1,7 +1,9 @@ -import type { Cluster, Kubeconfig, Prisma, Project, Stage, Zone } from '@prisma/client' -import type { PrismaService } from '../infrastructure/database/prisma.service' +import type { Kubeconfig as KubeconfigBody } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, Stage, User, Zone } from '@prisma/client' +import { ClusterPrivacySchema } from '@cpn-console/shared' + +const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public -// ── selects ─────────────────────────────────────────────────────────────────────────── export const clusterListSelect = { id: true, label: true, @@ -63,97 +65,108 @@ export const clusterEnvironmentsSelect = { } satisfies Prisma.EnvironmentSelect export type ClusterEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof clusterEnvironmentsSelect }> -// ── query: getClusterById ─────────────────────────────────────────────────────────── -export function getClusterById(prisma: PrismaService, id: Cluster['id']) { +export function getClusterById(prisma: Prisma.TransactionClient, id: Cluster['id']) { return prisma.cluster.findUnique({ where: { id }, include: { kubeconfig: true }, }) } -// ── query: getClusterEnvironments ──────────────────────────────────────────────────── -export function getClusterEnvironments(prisma: PrismaService, clusterId: Cluster['id']) { +export function getClusterEnvironments(prisma: Prisma.TransactionClient, clusterId: Cluster['id']) { return prisma.environment.findMany({ where: { clusterId }, select: clusterEnvironmentsSelect, }) } -// ── query: getClusterDetails ───────────────────────────────────────────────────────── -export function getClusterDetails(prisma: PrismaService, id: Cluster['id']) { +export function getClusterDetails(prisma: Prisma.TransactionClient, id: Cluster['id']) { return prisma.cluster.findUniqueOrThrow({ where: { id }, select: clusterDetailsSelect, }) } -// ── query: getClusterByLabel ────────────────────────────────────────────────────────── -export function getClusterByLabel(prisma: PrismaService, label: Cluster['label']) { +export function getClusterByLabel(prisma: Prisma.TransactionClient, label: Cluster['label']) { return prisma.cluster.findUnique({ where: { label } }) } -// ── query: listClusters ────────────────────────────────────────────────────────────── -export function listClusters(prisma: PrismaService, where: Prisma.ClusterWhereInput) { +export function listClusters(prisma: Prisma.TransactionClient, where: Prisma.ClusterWhereInput) { return prisma.cluster.findMany({ where, select: clusterListSelect, }) } -// ── query: getProjectsByClusterId ───────────────────────────────────────────────────── -export async function getProjectsByClusterId(prisma: PrismaService, id: Cluster['id']) { +export function listClustersWhere(userId?: User['id']): Prisma.ClusterWhereInput { + return userId + ? { + OR: [ + { privacy: CLUSTER_PUBLIC }, + { projects: { some: { members: { some: { userId } } } } }, + { projects: { some: { ownerId: userId } } }, + { environments: { some: { project: { members: { some: { userId } } } } } }, + ], + } + : {} +} + +export async function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: Cluster['id']) { return (await prisma.cluster.findUniqueOrThrow({ where: { id }, select: { projects: true }, }))?.projects } -// ── query: listStagesByClusterId ────────────────────────────────────────────────────── -export async function listStagesByClusterId(prisma: PrismaService, id: Cluster['id']) { +export async function listStagesByClusterId(prisma: Prisma.TransactionClient, id: Cluster['id']) { return (await prisma.cluster.findUniqueOrThrow({ where: { id }, select: { stages: true }, }))?.stages } -// ── query: createCluster ───────────────────────────────────────────────────────────── export function createCluster( - prisma: PrismaService, + prisma: Prisma.TransactionClient, data: Omit, - kubeconfig: Pick, + kubeconfig: Pick, zoneId: string, ) { return prisma.cluster.create({ data: { ...data, - // @ts-ignore - kubeconfig: { create: kubeconfig }, + kubeconfig: { + create: { + user: kubeconfig.user, + cluster: kubeconfig.cluster, + }, + }, zone: { connect: { id: zoneId } }, }, }) } -// ── query: updateCluster ───────────────────────────────────────────────────────────── export function updateCluster( - prisma: PrismaService, + prisma: Prisma.TransactionClient, id: Cluster['id'], - data: Partial>, - kubeconfig: Pick, + data: Partial>, + kubeconfig?: Pick, ) { return prisma.cluster.update({ where: { id }, - data: { - ...data, - kubeconfig: { - // @ts-ignore - update: kubeconfig, - }, - }, + data: kubeconfig + ? { + ...data, + kubeconfig: { + update: { + user: kubeconfig.user, + cluster: kubeconfig.cluster, + }, + }, + } + : data, }) } -// ── query: linkClusterToProjects ────────────────────────────────────────────────────── -export function linkClusterToProjects(prisma: PrismaService, id: Cluster['id'], projectIds: Project['id'][]) { +export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: Cluster['id'], projectIds: Project['id'][]) { return prisma.cluster.update({ where: { id }, data: { @@ -162,8 +175,7 @@ export function linkClusterToProjects(prisma: PrismaService, id: Cluster['id'], }) } -// ── query: linkClusterToStages ──────────────────────────────────────────────────────── -export function linkClusterToStages(prisma: PrismaService, id: Cluster['id'], stageIds: Stage['id'][]) { +export function linkClusterToStages(prisma: Prisma.TransactionClient, id: Cluster['id'], stageIds: Stage['id'][]) { return prisma.cluster.update({ where: { id }, data: { @@ -172,8 +184,7 @@ export function linkClusterToStages(prisma: PrismaService, id: Cluster['id'], st }) } -// ── query: removeClusterFromProject ─────────────────────────────────────────────────── -export function removeClusterFromProject(prisma: PrismaService, id: Cluster['id'], projectId: Project['id']) { +export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: Cluster['id'], projectId: Project['id']) { return prisma.cluster.update({ where: { id }, data: { @@ -182,8 +193,7 @@ export function removeClusterFromProject(prisma: PrismaService, id: Cluster['id' }) } -// ── query: removeClusterFromStage ───────────────────────────────────────────────────── -export function removeClusterFromStage(prisma: PrismaService, id: Cluster['id'], stageId: Stage['id']) { +export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: Cluster['id'], stageId: Stage['id']) { return prisma.cluster.update({ where: { id }, data: { @@ -192,13 +202,11 @@ export function removeClusterFromStage(prisma: PrismaService, id: Cluster['id'], }) } -// ── query: deleteCluster ───────────────────────────────────────────────────────────── -export function deleteCluster(prisma: PrismaService, id: Cluster['id']) { +export function deleteCluster(prisma: Prisma.TransactionClient, id: Cluster['id']) { return prisma.cluster.delete({ where: { id } }) } -// ── query: linkZoneToClusters ──────────────────────────────────────────────────────── -export function linkZoneToClusters(prisma: PrismaService, zoneId: Zone['id'], clusterIds: Cluster['id'][]) { +export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: Zone['id'], clusterIds: Cluster['id'][]) { return prisma.zone.update({ where: { id: zoneId }, data: { @@ -207,8 +215,7 @@ export function linkZoneToClusters(prisma: PrismaService, zoneId: Zone['id'], cl }) } -// ── query: getClusterUsage ─────────────────────────────────────────────────────────── -export async function getClusterUsage(prisma: PrismaService, clusterId: Cluster['id']) { +export async function getClusterUsage(prisma: Prisma.TransactionClient, clusterId: Cluster['id']) { const clusterUsage = await prisma.environment.aggregate({ _sum: { memory: true, cpu: true, gpu: true }, where: { clusterId }, diff --git a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts index b1b502d634..facc294042 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -1,6 +1,8 @@ -import type { Cluster, Environment, Kubeconfig, ProjectMembers, Stage, User } from '@prisma/client' -import { faker } from '@faker-js/faker' +import type { Cluster, Kubeconfig, Stage } from '@prisma/client' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' +import { faker } from '@faker-js/faker' +import { makeProjectMembers } from '../project-members/project-members-testing.utils' +import { makeUser } from '../project/project-testing.utils' export function makeCluster(overrides: Partial = {}): Cluster { return { @@ -29,30 +31,6 @@ export function makeStage(overrides: Partial = {}): Stage { } satisfies Stage } -export function makeUser(overrides: Partial = {}): User { - return { - id: faker.string.uuid(), - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - email: faker.internet.email(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), - lastLogin: faker.date.past(), - adminRoleIds: [], - type: 'human', - ...overrides, - } satisfies User -} - -export function makeProjectMember(overrides: Partial = {}): ProjectMembers { - return { - projectId: faker.string.uuid(), - userId: faker.string.uuid(), - roleIds: [], - ...overrides, - } satisfies ProjectMembers -} - export function makeClusterListRecord(overrides: Partial = {}): ClusterListRecord { return { ...makeCluster(), @@ -88,7 +66,7 @@ export function makeClusterEnvironmentsRecord(overrides: Partial = {}): Kubeconfig ...overrides, } satisfies Kubeconfig } - -export function makeEnvironment(overrides: Partial = {}): Environment { - return { - id: faker.string.uuid(), - name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 11), - projectId: faker.string.uuid(), - memory: faker.number.int({ min: 0, max: 64 }), - cpu: faker.number.int({ min: 0, max: 16 }), - gpu: faker.number.int({ min: 0, max: 4 }), - autosync: faker.datatype.boolean(), - clusterId: faker.string.uuid(), - stageId: faker.string.uuid(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), - ...overrides, - } satisfies Environment -} diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index 9d90055f24..ea81d3f88b 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -1,7 +1,12 @@ +import type { clusterContract, CreateClusterBody, DeleteClusterQuery, UpdateClusterBody } from '@cpn-console/shared' import type { ClientInferResponseBody } from '@ts-rest/core' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' -import { type CreateClusterBody, clusterContract, type UpdateClusterBody } from '@cpn-console/shared' +import { + CreateClusterBodySchema, + DeleteClusterQuerySchema, + UpdateClusterBodySchema, +} from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' @@ -46,7 +51,7 @@ export class ClusterController { @RequireAdminPermission('ManageClusters') @HttpCode(HttpStatus.CREATED) create( - @Body(new ZodValidationPipe(clusterContract.createCluster.body)) data: CreateClusterBody, + @Body(new ZodValidationPipe(CreateClusterBodySchema)) data: CreateClusterBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { @@ -58,7 +63,7 @@ export class ClusterController { @HttpCode(HttpStatus.OK) update( @Param('clusterId') clusterId: string, - @Body(new ZodValidationPipe(clusterContract.updateCluster.body)) data: UpdateClusterBody, + @Body(new ZodValidationPipe(UpdateClusterBodySchema)) data: UpdateClusterBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { @@ -70,7 +75,7 @@ export class ClusterController { @HttpCode(HttpStatus.NO_CONTENT) delete( @Param('clusterId') clusterId: string, - @Query(new ZodValidationPipe(clusterContract.deleteCluster.query)) { force }: { force?: boolean }, + @Query(new ZodValidationPipe(DeleteClusterQuerySchema)) { force }: DeleteClusterQuery, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts index f59699ca16..3c7c6f9c72 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -1,11 +1,13 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnprocessableEntityException } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { faker } from '@faker-js/faker' -import { EventEmitter2 } from '@nestjs/event-emitter' import { baseConfigFactory } from '../../config/base.config' +import { makeEnvironment } from '../environment/environment-testing.utils' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { @@ -13,11 +15,10 @@ import { makeClusterDetailsRecord, makeClusterEnvironmentsRecord, makeClusterListRecord, - makeEnvironment, } from './cluster-testing.utils' import { ClusterService } from './cluster.service' -describe('ClusterService', () => { +describe('clusterService', () => { let service: ClusterService let prisma: DeepMockProxy let logs: DeepMockProxy @@ -26,6 +27,7 @@ describe('ClusterService', () => { beforeEach(async () => { prisma = mockDeep() + prisma.$transaction.mockImplementation(async (cb: (tx: unknown) => unknown) => cb(prisma)) logs = mockDeep() events = mockDeep() baseConfig = mockDeep>() @@ -231,4 +233,16 @@ describe('ClusterService', () => { memory: env.memory, }))) }) + + it('propagates upsert hook failure as 422', async () => { + const record = makeClusterDetailsRecord() + prisma.cluster.findUnique.mockResolvedValue(record as never) + prisma.cluster.update.mockResolvedValue(record as never) + prisma.zone.update.mockResolvedValue(record as never) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ projects: [] } as never) + events.emitAsync.mockRejectedValue(new Error('hook down')) + + await expect(service.updateCluster({ infos: 'x' }, record.id, 'u', 'r')) + .rejects.toThrow(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) + }) }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 8e5a729547..f3a0fea90c 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -7,13 +7,10 @@ import type { UpdateClusterBody, } from '@cpn-console/shared' import type { ConfigType } from '@nestjs/config' -import type { Cluster, Prisma, Project, User } from '@prisma/client' +import type { Cluster, Project, User } from '@prisma/client' import type { ClientInferResponseBody } from '@ts-rest/core' -import { - ClusterPrivacySchema, - KubeconfigSchema, -} from '@cpn-console/shared' -import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { ClusterPrivacySchema, KubeconfigSchema } from '@cpn-console/shared' +import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException, UnprocessableEntityException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -31,6 +28,7 @@ import { linkClusterToStages, linkZoneToClusters, listClusters as listClustersQuery, + listClustersWhere, listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, @@ -54,16 +52,7 @@ export class ClusterService { ) {} async listClusters(userId?: User['id']): Promise { - const where: Prisma.ClusterWhereInput = userId - ? { - OR: [ - { privacy: CLUSTER_PUBLIC }, - { projects: { some: { members: { some: { userId } } } } }, - { projects: { some: { ownerId: userId } } }, - { environments: { some: { project: { members: { some: { userId } } } } } }, - ], - } - : {} + const where = listClustersWhere(userId) const clusters = await listClustersQuery(this.prisma, where) return clusters.map(({ stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster }) => ({ ...cluster, @@ -112,15 +101,19 @@ export class ClusterService { const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data - const clusterCreated = await createClusterQuery(this.prisma, clusterData, kubeconfig, zoneId) + const clusterCreated = await this.prisma.$transaction(async (tx) => { + const clusterCreated = await createClusterQuery(tx, clusterData, kubeconfig, zoneId) - if (data.privacy !== CLUSTER_PUBLIC && projectIds?.length) { - await linkClusterToProjects(this.prisma, clusterCreated.id, projectIds) - } + if (data.privacy !== CLUSTER_PUBLIC && projectIds?.length) { + await linkClusterToProjects(tx, clusterCreated.id, projectIds) + } - if (stageIds?.length) { - await linkClusterToStages(this.prisma, clusterCreated.id, stageIds) - } + if (stageIds?.length) { + await linkClusterToStages(tx, clusterCreated.id, stageIds) + } + + return clusterCreated + }) await this.upsertClusterHook(clusterCreated.id, zoneId) await this.logs.addLog({ @@ -146,43 +139,43 @@ export class ClusterService { const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data - const clusterUpdated = await updateClusterQuery(this.prisma, clusterId, clusterData, - // @ts-ignore - kubeconfig) + await this.prisma.$transaction(async (tx) => { + const clusterUpdated = await updateClusterQuery(tx, clusterId, clusterData, kubeconfig) - if (zoneId) { - await linkZoneToClusters(this.prisma, zoneId, [clusterId]) - } + if (zoneId) { + await linkZoneToClusters(tx, zoneId, [clusterId]) + } - const dbProjects = await getProjectsByClusterId(this.prisma, clusterId) + const dbProjects = await getProjectsByClusterId(tx, clusterId) - let projectsToRemove: Project['id'][] = [] + let projectsToRemove: Project['id'][] = [] - if (projectIds && clusterUpdated.privacy === CLUSTER_PUBLIC) { - projectsToRemove = dbProjects?.map(project => project.id) ?? [] - } else if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { - await linkClusterToProjects(this.prisma, clusterId, projectIds) - projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] - } else if (clusterUpdated.privacy === CLUSTER_PUBLIC) { - projectsToRemove = dbProjects?.map(project => project.id) ?? [] - } + if (projectIds && clusterUpdated.privacy === CLUSTER_PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } else if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { + await linkClusterToProjects(tx, clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === CLUSTER_PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } - for (const projectId of projectsToRemove) { - await removeClusterFromProject(this.prisma, clusterUpdated.id, projectId) - } + for (const projectId of projectsToRemove) { + await removeClusterFromProject(tx, clusterUpdated.id, projectId) + } - if (stageIds) { - await linkClusterToStages(this.prisma, clusterId, stageIds) + if (stageIds) { + await linkClusterToStages(tx, clusterId, stageIds) - const dbStages = await listStagesByClusterId(this.prisma, clusterId) - if (dbStages) { - for (const stage of dbStages) { - if (!stageIds.includes(stage.id)) { - await removeClusterFromStage(this.prisma, clusterUpdated.id, stage.id) + const dbStages = await listStagesByClusterId(tx, clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(tx, clusterUpdated.id, stage.id) + } } } } - } + }) await this.upsertClusterHook(clusterId, dbCluster.zoneId) await this.logs.addLog({ @@ -207,15 +200,19 @@ export class ClusterService { force?: boolean }): Promise { let message: string | null = null - if (force) { - const envs = await this.prisma.environment.deleteMany({ - where: { clusterId }, - }) - message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` - } else { - const environment = await this.prisma.environment.findFirst({ where: { clusterId } }) - if (environment) throw new BadRequestException('Impossible de supprimer le cluster, des environnements en activité y sont déployés') - } + await this.prisma.$transaction(async (tx) => { + if (force) { + const envs = await tx.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await tx.environment.findFirst({ where: { clusterId } }) + if (environment) throw new BadRequestException('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + await deleteClusterQuery(tx, clusterId) + }) await this.deleteClusterHook(clusterId) await this.logs.addLog({ @@ -225,17 +222,15 @@ export class ClusterService { requestId, }) - await deleteClusterQuery(this.prisma, clusterId) return message } - // ── Cluster hook helpers ──────────────────────────────────────────────────────── - private async upsertClusterHook(clusterId: string, zoneId: Cluster['zoneId']): Promise { try { await this.eventEmitter.emitAsync('cluster.upsert', { clusterId, zoneId }) } catch (error) { this.logger.error(`cluster.upsert hook failed (clusterId=${clusterId})`, error instanceof Error ? error.stack : String(error)) + throw new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster') } } diff --git a/packages/shared/src/contracts/cluster.ts b/packages/shared/src/contracts/cluster.ts index 96ada83d77..14db5a3790 100644 --- a/packages/shared/src/contracts/cluster.ts +++ b/packages/shared/src/contracts/cluster.ts @@ -1,13 +1,14 @@ import type { ClientInferResponseBody } from '@ts-rest/core' -import type Zod from 'zod' import { ContractNoBody } from '@ts-rest/core' import { z } from 'zod' import { apiPrefix, contractInstance } from '../api-client.js' -import { CoerceBooleanSchema } from '../schemas/_utils.js' import { CleanedClusterSchema, ClusterDetailsSchema, ClusterUsageSchema, + CreateClusterBodySchema, + DeleteClusterQuerySchema, + UpdateClusterBodySchema, } from '../schemas/cluster.js' import { EnvironmentSchema } from '../schemas/environment.js' import { UserSchema } from '../schemas/user.js' @@ -36,7 +37,7 @@ export const clusterContract = contractInstance.router({ contentType: 'application/json', summary: 'Create cluster', description: 'Create new cluster.', - body: ClusterDetailsSchema.omit({ id: true }), + body: CreateClusterBodySchema, responses: { 201: ClusterDetailsSchema, 400: ErrorSchema, @@ -101,7 +102,7 @@ export const clusterContract = contractInstance.router({ summary: 'Update cluster', description: 'Update a cluster by its ID.', pathParams: ClusterParams, - body: ClusterDetailsSchema.omit({ id: true }).partial(), + body: UpdateClusterBodySchema, responses: { 200: ClusterDetailsSchema, 400: ErrorSchema, @@ -116,7 +117,7 @@ export const clusterContract = contractInstance.router({ path: `/:clusterId`, summary: 'Delete cluster', description: 'Delete a cluster by its ID.', - query: z.object({ force: CoerceBooleanSchema.optional() }), + query: DeleteClusterQuerySchema, pathParams: ClusterParams, body: ContractNoBody, responses: { @@ -134,5 +135,3 @@ export const clusterContract = contractInstance.router({ }) export type ClusterAssociatedEnvironments = ClientInferResponseBody -export type CreateClusterBody = Zod.infer -export type UpdateClusterBody = Zod.infer diff --git a/packages/shared/src/schemas/cluster.ts b/packages/shared/src/schemas/cluster.ts index eec4908afa..a2faf7be6c 100644 --- a/packages/shared/src/schemas/cluster.ts +++ b/packages/shared/src/schemas/cluster.ts @@ -1,6 +1,8 @@ import type Zod from 'zod' import { z } from 'zod' +import { CoerceBooleanSchema } from './_utils.js' + export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) export const clusterLabelValidationMessage = 'Le nom du cluster doit contenir uniquement des lettres minuscules, des chiffres et des traits d’union, et commencer et terminer par un caractère alphanumérique.' @@ -62,6 +64,14 @@ export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ kubeconfig: KubeconfigSchema, })) +export const CreateClusterBodySchema = ClusterDetailsSchema.omit({ id: true }) + +export const UpdateClusterBodySchema = CreateClusterBodySchema.partial() + +export const DeleteClusterQuerySchema = z.object({ + force: CoerceBooleanSchema.optional(), +}) + export const ClusterUsageSchema = z.object({ cpu: z.number(), gpu: z.number(), @@ -71,5 +81,8 @@ export const ClusterUsageSchema = z.object({ export type Cluster = Zod.infer export type ClusterDetails = Zod.infer export type Kubeconfig = Zod.infer +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer +export type DeleteClusterQuery = Zod.infer export type CleanedCluster = Zod.infer From 263f9124ee31ce9253dde84591724195505b908d Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:49:11 +0200 Subject: [PATCH 06/22] refactor(cluster): use shared ClusterUsage type and plain string ids Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster-queries.utils.ts | 32 +++++++++---------- .../src/modules/cluster/cluster.service.ts | 16 ++++------ packages/shared/src/schemas/cluster.ts | 1 + 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index 2958bfebd2..fbdb6e3795 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -1,5 +1,5 @@ import type { Kubeconfig as KubeconfigBody } from '@cpn-console/shared' -import type { Cluster, Prisma, Project, Stage, User, Zone } from '@prisma/client' +import type { Cluster, Prisma } from '@prisma/client' import { ClusterPrivacySchema } from '@cpn-console/shared' const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public @@ -65,28 +65,28 @@ export const clusterEnvironmentsSelect = { } satisfies Prisma.EnvironmentSelect export type ClusterEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof clusterEnvironmentsSelect }> -export function getClusterById(prisma: Prisma.TransactionClient, id: Cluster['id']) { +export function getClusterById(prisma: Prisma.TransactionClient, id: string) { return prisma.cluster.findUnique({ where: { id }, include: { kubeconfig: true }, }) } -export function getClusterEnvironments(prisma: Prisma.TransactionClient, clusterId: Cluster['id']) { +export function getClusterEnvironments(prisma: Prisma.TransactionClient, clusterId: string) { return prisma.environment.findMany({ where: { clusterId }, select: clusterEnvironmentsSelect, }) } -export function getClusterDetails(prisma: Prisma.TransactionClient, id: Cluster['id']) { +export function getClusterDetails(prisma: Prisma.TransactionClient, id: string) { return prisma.cluster.findUniqueOrThrow({ where: { id }, select: clusterDetailsSelect, }) } -export function getClusterByLabel(prisma: Prisma.TransactionClient, label: Cluster['label']) { +export function getClusterByLabel(prisma: Prisma.TransactionClient, label: string) { return prisma.cluster.findUnique({ where: { label } }) } @@ -97,7 +97,7 @@ export function listClusters(prisma: Prisma.TransactionClient, where: Prisma.Clu }) } -export function listClustersWhere(userId?: User['id']): Prisma.ClusterWhereInput { +export function listClustersWhere(userId?: string): Prisma.ClusterWhereInput { return userId ? { OR: [ @@ -110,14 +110,14 @@ export function listClustersWhere(userId?: User['id']): Prisma.ClusterWhereInput : {} } -export async function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: Cluster['id']) { +export async function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: string) { return (await prisma.cluster.findUniqueOrThrow({ where: { id }, select: { projects: true }, }))?.projects } -export async function listStagesByClusterId(prisma: Prisma.TransactionClient, id: Cluster['id']) { +export async function listStagesByClusterId(prisma: Prisma.TransactionClient, id: string) { return (await prisma.cluster.findUniqueOrThrow({ where: { id }, select: { stages: true }, @@ -146,7 +146,7 @@ export function createCluster( export function updateCluster( prisma: Prisma.TransactionClient, - id: Cluster['id'], + id: string, data: Partial>, kubeconfig?: Pick, ) { @@ -166,7 +166,7 @@ export function updateCluster( }) } -export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: Cluster['id'], projectIds: Project['id'][]) { +export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: string, projectIds: string[]) { return prisma.cluster.update({ where: { id }, data: { @@ -175,7 +175,7 @@ export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: Clus }) } -export function linkClusterToStages(prisma: Prisma.TransactionClient, id: Cluster['id'], stageIds: Stage['id'][]) { +export function linkClusterToStages(prisma: Prisma.TransactionClient, id: string, stageIds: string[]) { return prisma.cluster.update({ where: { id }, data: { @@ -184,7 +184,7 @@ export function linkClusterToStages(prisma: Prisma.TransactionClient, id: Cluste }) } -export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: Cluster['id'], projectId: Project['id']) { +export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: string, projectId: string) { return prisma.cluster.update({ where: { id }, data: { @@ -193,7 +193,7 @@ export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: C }) } -export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: Cluster['id'], stageId: Stage['id']) { +export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: string, stageId: string) { return prisma.cluster.update({ where: { id }, data: { @@ -202,11 +202,11 @@ export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: Clu }) } -export function deleteCluster(prisma: Prisma.TransactionClient, id: Cluster['id']) { +export function deleteCluster(prisma: Prisma.TransactionClient, id: string) { return prisma.cluster.delete({ where: { id } }) } -export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: Zone['id'], clusterIds: Cluster['id'][]) { +export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: string, clusterIds: string[]) { return prisma.zone.update({ where: { id: zoneId }, data: { @@ -215,7 +215,7 @@ export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: Zon }) } -export async function getClusterUsage(prisma: Prisma.TransactionClient, clusterId: Cluster['id']) { +export async function getClusterUsage(prisma: Prisma.TransactionClient, clusterId: string) { const clusterUsage = await prisma.environment.aggregate({ _sum: { memory: true, cpu: true, gpu: true }, where: { clusterId }, diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index f3a0fea90c..2c497f1b0d 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -1,14 +1,12 @@ import type { CleanedCluster, ClusterAssociatedEnvironments, - clusterContract, ClusterDetails, + ClusterUsage, CreateClusterBody, UpdateClusterBody, } from '@cpn-console/shared' import type { ConfigType } from '@nestjs/config' -import type { Cluster, Project, User } from '@prisma/client' -import type { ClientInferResponseBody } from '@ts-rest/core' import { ClusterPrivacySchema, KubeconfigSchema } from '@cpn-console/shared' import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException, UnprocessableEntityException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' @@ -35,8 +33,6 @@ import { updateCluster as updateClusterQuery, } from './cluster-queries.utils' -type ClusterUsage = ClientInferResponseBody - const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public const CLUSTER_DEDICATED = ClusterPrivacySchema.enum.dedicated @@ -51,7 +47,7 @@ export class ClusterService { @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} - async listClusters(userId?: User['id']): Promise { + async listClusters(userId?: string): Promise { const where = listClustersWhere(userId) const clusters = await listClustersQuery(this.prisma, where) return clusters.map(({ stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster }) => ({ @@ -93,7 +89,7 @@ export class ClusterService { async createCluster( data: CreateClusterBody, - userId: User['id'], + userId: string, requestId: string, ): Promise { const isLabelTaken = await getClusterByLabel(this.prisma, data.label) @@ -129,7 +125,7 @@ export class ClusterService { async updateCluster( data: UpdateClusterBody, clusterId: string, - userId: User['id'], + userId: string, requestId: string, ): Promise { if (data?.privacy === CLUSTER_PUBLIC) delete data.projectIds @@ -148,7 +144,7 @@ export class ClusterService { const dbProjects = await getProjectsByClusterId(tx, clusterId) - let projectsToRemove: Project['id'][] = [] + let projectsToRemove: string[] = [] if (projectIds && clusterUpdated.privacy === CLUSTER_PUBLIC) { projectsToRemove = dbProjects?.map(project => project.id) ?? [] @@ -225,7 +221,7 @@ export class ClusterService { return message } - private async upsertClusterHook(clusterId: string, zoneId: Cluster['zoneId']): Promise { + private async upsertClusterHook(clusterId: string, zoneId: string): Promise { try { await this.eventEmitter.emitAsync('cluster.upsert', { clusterId, zoneId }) } catch (error) { diff --git a/packages/shared/src/schemas/cluster.ts b/packages/shared/src/schemas/cluster.ts index a2faf7be6c..4722bd9b94 100644 --- a/packages/shared/src/schemas/cluster.ts +++ b/packages/shared/src/schemas/cluster.ts @@ -86,3 +86,4 @@ export type UpdateClusterBody = Zod.infer export type DeleteClusterQuery = Zod.infer export type CleanedCluster = Zod.infer +export type ClusterUsage = Zod.infer From 047b15bdb77d304cd62cf7755cd0030687115aaf Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:19:09 +0200 Subject: [PATCH 07/22] fix(cluster): wire missing module imports ClusterService injects LogService and the controller guard requires auth and user-permission providers that ClusterModule never imported; the backend crashed at boot. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I4a060cde917ff9c62ac4779f2810f05c6a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/modules/cluster/cluster.module.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.module.ts b/apps/server-nestjs/src/modules/cluster/cluster.module.ts index a63c33650a..3c06d45262 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.module.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' import { ClusterController } from './cluster.controller' import { ClusterService } from './cluster.service' @Module({ - imports: [DatabaseModule], + imports: [AuthModule, DatabaseModule, UserPermissionModule, LogModule], controllers: [ClusterController], providers: [ClusterService], exports: [ClusterService], From 807cb6529c2a9a9ed2f1c80593e214456e8effc9 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:44:15 +0200 Subject: [PATCH 08/22] fix(server-nestjs): provide EventEmitter2 to ClusterModule ClusterService injects EventEmitter2; import EventsModule so the provider resolves and the backend stops crashing at boot. Co-authored-by: Automata Signed-off-by: William Phetsinorath Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/modules/cluster/cluster.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.module.ts b/apps/server-nestjs/src/modules/cluster/cluster.module.ts index 3c06d45262..dc782e71f2 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.module.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { LogModule } from '../log/log.module' import { ClusterController } from './cluster.controller' import { ClusterService } from './cluster.service' @Module({ - imports: [AuthModule, DatabaseModule, UserPermissionModule, LogModule], + imports: [AuthModule, DatabaseModule, EventsModule, UserPermissionModule, LogModule], controllers: [ClusterController], providers: [ClusterService], exports: [ClusterService], From ff92ce154b4370c5632498afa5eb653bc1167650 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:42:05 +0200 Subject: [PATCH 09/22] test(cluster): lock controller permission matrix Co-authored-by: Automata Signed-off-by: William Phetsinorath Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../cluster/cluster.controller.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts new file mode 100644 index 0000000000..9536e27d13 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -0,0 +1,107 @@ +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeClusterDetailsRecord, makeClusterListRecord } from './cluster-testing.utils' +import { ClusterController } from './cluster.controller' +import { ClusterService } from './cluster.service' + +describe('clusterController', () => { + let module: TestingModule + let controller: ClusterController + let service: MockProxy + + const user = { userId: faker.string.uuid() } as UserContext + const request = { id: faker.string.uuid() } as FastifyRequest + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ClusterController], + providers: [ + { provide: ClusterService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ClusterController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('guards reads with ListClusters', () => { + for (const handler of [ClusterController.prototype.list, ClusterController.prototype.getDetails, ClusterController.prototype.getUsage, ClusterController.prototype.getEnvironments]) { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, handler)).toEqual(['ListClusters']) + } + }) + + it('guards mutations with ManageClusters', () => { + for (const handler of [ClusterController.prototype.create, ClusterController.prototype.update, ClusterController.prototype.delete]) { + expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, handler)).toEqual(['ManageClusters']) + } + }) + + it('delegates list to the service', async () => { + const clusters = [makeClusterListRecord()] + service.listClusters.mockResolvedValue(clusters as never) + + expect(await controller.list()).toBe(clusters) + expect(service.listClusters).toHaveBeenCalledTimes(1) + }) + + it('delegates details, usage and environments with clusterId', async () => { + const clusterId = faker.string.uuid() + const details = makeClusterDetailsRecord() + service.getClusterDetails.mockResolvedValue(details as never) + service.getClusterUsage.mockResolvedValue({} as never) + service.getClusterAssociatedEnvironments.mockResolvedValue([]) + + expect(await controller.getDetails(clusterId)).toBe(details) + expect(service.getClusterDetails).toHaveBeenCalledWith(clusterId) + await controller.getUsage(clusterId) + expect(service.getClusterUsage).toHaveBeenCalledWith(clusterId) + await controller.getEnvironments(clusterId) + expect(service.getClusterAssociatedEnvironments).toHaveBeenCalledWith(clusterId) + }) + + it('delegates create with body, userId and requestId', async () => { + const details = makeClusterDetailsRecord() + service.createCluster.mockResolvedValue(details as never) + + expect(await controller.create(details as never, user, request)).toBe(details) + expect(service.createCluster).toHaveBeenCalledWith(details, user.userId, request.id) + }) + + it('delegates update with clusterId, body, userId and requestId', async () => { + const clusterId = faker.string.uuid() + const details = makeClusterDetailsRecord() + service.updateCluster.mockResolvedValue(details as never) + + expect(await controller.update(clusterId, { label: 'new' } as never, user, request)).toBe(details) + expect(service.updateCluster).toHaveBeenCalledWith({ label: 'new' }, clusterId, user.userId, request.id) + }) + + it('delegates delete with clusterId, userId, requestId and force', async () => { + const clusterId = faker.string.uuid() + service.deleteCluster.mockResolvedValue(null) + + await controller.delete(clusterId, { force: true } as never, user, request) + expect(service.deleteCluster).toHaveBeenCalledWith({ + clusterId, + userId: user.userId, + requestId: request.id, + force: true, + }) + }) +}) From cae9d48dd378894d1cec1babdc9c1cbca0fe10d0 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:25:41 +0200 Subject: [PATCH 10/22] fix(server-nestjs): wire ClusterModule provider imports ClusterService injects LogService and EventEmitter2 and the controller guard requires auth and user-permission providers that ClusterModule never imported; the backend crashed at boot. Co-authored-by: Automata Signed-off-by: William Phetsinorath Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../src/modules/cluster/cluster.module.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.module.ts b/apps/server-nestjs/src/modules/cluster/cluster.module.ts index dc782e71f2..f1a8caf58f 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.module.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -1,4 +1,6 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' @@ -8,7 +10,14 @@ import { ClusterController } from './cluster.controller' import { ClusterService } from './cluster.service' @Module({ - imports: [AuthModule, DatabaseModule, EventsModule, UserPermissionModule, LogModule], + imports: [ + AuthModule, + ConfigModule.forFeature(baseConfigFactory), + DatabaseModule, + EventsModule, + LogModule, + UserPermissionModule, + ], controllers: [ClusterController], providers: [ClusterService], exports: [ClusterService], From 18d7d05e0d1bb1f7bc82e46b5d1bdd45114769f7 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:26:37 +0200 Subject: [PATCH 11/22] fix(cluster): drop invalid guard metadata specs Reflect-based guard metadata assertions pass whether or not the guard is enforced and add no behavioural coverage; the guard spec and the controller delegation tests are untouched. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Id551b411ab24291da518f2599ae1edd66a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../src/modules/cluster/cluster.controller.spec.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts index 9536e27d13..801d51aa93 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -6,7 +6,6 @@ import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mock } from 'vitest-mock-extended' -import { ADMIN_PERMISSIONS_KEY } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' import { makeClusterDetailsRecord, makeClusterListRecord } from './cluster-testing.utils' import { ClusterController } from './cluster.controller' @@ -40,18 +39,6 @@ describe('clusterController', () => { expect(controller).toBeDefined() }) - it('guards reads with ListClusters', () => { - for (const handler of [ClusterController.prototype.list, ClusterController.prototype.getDetails, ClusterController.prototype.getUsage, ClusterController.prototype.getEnvironments]) { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, handler)).toEqual(['ListClusters']) - } - }) - - it('guards mutations with ManageClusters', () => { - for (const handler of [ClusterController.prototype.create, ClusterController.prototype.update, ClusterController.prototype.delete]) { - expect(Reflect.getMetadata(ADMIN_PERMISSIONS_KEY, handler)).toEqual(['ManageClusters']) - } - }) - it('delegates list to the service', async () => { const clusters = [makeClusterListRecord()] service.listClusters.mockResolvedValue(clusters as never) From eea46bae5b87e0f7435c8919d014875d076eefca Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:58:04 +0200 Subject: [PATCH 12/22] refactor(events): move plugin-failure throws into AppEventsService Signed-off-by: William Phetsinorath Change-Id: Ie239c572870d655ca697a5ad53e487496a6a6964 Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I8f0a537fd3f724c1bcd283997171a9a76a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Signed-off-by: William Phetsinorath Change-Id: Iea5eabc6a0b5ff15d0b32365897ec56a6a6a6964 --- .../argocd/argocd-datastore.service.ts | 44 ++++ .../src/modules/argocd/argocd.service.ts | 81 ++++++- .../src/modules/argocd/argocd.utils.ts | 33 +++ .../modules/cluster/cluster-testing.utils.ts | 49 ++++- .../cluster/cluster.controller.spec.ts | 162 +++++++++++--- .../src/modules/cluster/cluster.controller.ts | 41 ++-- .../src/modules/cluster/cluster.module.ts | 7 +- .../modules/cluster/cluster.service.spec.ts | 134 ++++++------ .../src/modules/cluster/cluster.service.ts | 202 ++++++++---------- .../src/modules/cluster/cluster.utils.ts | 41 ++++ .../environment/environment.service.spec.ts | 16 +- .../environment/environment.service.ts | 16 +- .../src/modules/events/app-events.service.ts | 44 +++- .../repository/repository.service.spec.ts | 39 ++-- .../modules/repository/repository.service.ts | 27 +-- packages/shared/src/contracts/cluster.ts | 1 + 16 files changed, 633 insertions(+), 304 deletions(-) create mode 100644 apps/server-nestjs/src/modules/argocd/argocd.utils.ts create mode 100644 apps/server-nestjs/src/modules/cluster/cluster.utils.ts diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts index 407933a2d0..692bae93cc 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -112,6 +112,27 @@ export type ProjectWithDetails = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> +export const clusterSelect = { + label: true, + clusterResources: true, + kubeconfig: { + select: { + cluster: true, + user: true, + }, + }, + zone: { + select: { + id: true, + slug: true, + }, + }, +} satisfies Prisma.ClusterSelect + +export type ClusterWithZone = Prisma.ClusterGetPayload<{ + select: typeof clusterSelect +}> + @Injectable() export class ArgoCDDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} @@ -128,4 +149,27 @@ export class ArgoCDDatastoreService { }) return zones.map(zone => zone.slug) } + + async getCluster(clusterId: string): Promise { + return this.prisma.cluster.findUnique({ + where: { id: clusterId }, + select: clusterSelect, + }) + } + + async getZoneClusterNames(zoneId: string): Promise { + const zones = await this.prisma.zone.findUnique({ + where: { id: zoneId }, + select: { clusters: { select: { label: true } } }, + }) + return zones?.clusters.map(({ label }) => label) ?? [] + } + + async getZoneSlug(zoneId: string): Promise { + const zone = await this.prisma.zone.findUnique({ + where: { id: zoneId }, + select: { slug: true }, + }) + return zone?.slug ?? null + } } diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index dfa94b2417..c4c1dc4cbf 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,9 +1,10 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' import type { ConfigType } from '@nestjs/config' +import type { ClusterEventPayload } from '../events/app-events.service' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails } from './argocd-datastore.service' import { createHmac } from 'node:crypto' -import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { generateNamespaceName, inClusterLabel, KubeconfigSchema } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' @@ -27,6 +28,7 @@ import { PROJECT_READONLY_GROUP_PATH_SUFFIX, PROJECT_SECURITY_GROUP_PATH_SUFFIX, } from './argocd.constants' +import { generateClusterSecretData, generateZoneVaultValues } from './argocd.utils' @Injectable() export class ArgoCDService { @@ -68,6 +70,83 @@ export class ArgoCDService { return capturePluginResult('argocd', () => this.cleanupProject(project)) } + @OnEvent('cluster.upsert') + async handleClusterUpsert(payload: ClusterEventPayload): Promise> { + return capturePluginResult('argocd', () => this.syncCluster(payload)) + } + + @StartActiveSpan() + private async syncCluster(payload: ClusterEventPayload) { + const cluster = await this.datastore.getCluster(payload.clusterId) + if (!cluster) throw new Error(`Cluster not found for event (clusterId=${payload.clusterId})`) + const span = trace.getActiveSpan() + span?.setAttribute('cluster.label', cluster.label) + span?.setAttribute('zone.slug', cluster.zone.slug) + this.logger.log(`Handling a cluster upsert event for ${cluster.label}`) + const kubeconfig = KubeconfigSchema.parse(cluster.kubeconfig) + await this.vault.upsertKvData( + `zone-${cluster.zone.slug}`, + `clusters/cluster-${cluster.label}/argocd-cluster-secret`, + { data: generateClusterSecretData(cluster, kubeconfig) }, + ) + await this.commitZoneValues(cluster.zone.slug) + if (payload.zoneId && payload.zoneId !== cluster.zone.id) { + const previousZoneSlug = await this.datastore.getZoneSlug(payload.zoneId) + if (previousZoneSlug) await this.commitZoneValues(previousZoneSlug) + } + this.logger.log(`ArgoCD cluster sync completed for ${cluster.label}`) + } + + @OnEvent('cluster.delete') + async handleClusterDelete(payload: ClusterEventPayload): Promise> { + return capturePluginResult('argocd', () => this.cleanupCluster(payload)) + } + + @StartActiveSpan() + private async cleanupCluster(payload: ClusterEventPayload) { + const cluster = await this.datastore.getCluster(payload.clusterId) + if (!cluster) throw new Error(`Cluster not found for event (clusterId=${payload.clusterId})`) + const span = trace.getActiveSpan() + span?.setAttribute('cluster.label', cluster.label) + span?.setAttribute('zone.slug', cluster.zone.slug) + this.logger.log(`Handling a cluster delete event for ${cluster.label}`) + await this.vault.deleteKvMetadata( + `zone-${cluster.zone.slug}`, + `clusters/cluster-${cluster.label}/argocd-cluster-secret`, + ) + await this.commitZoneValues(cluster.zone.slug) + this.logger.log(`ArgoCD cluster cleanup completed for ${cluster.label}`) + } + + private async commitZoneValues(zoneSlug: string) { + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + const clusters = await this.datastore.getZoneClusterNames(zoneSlug) + const vaultValues = await this.generateZoneVaultValues(zoneSlug) + const action = await this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + 'argocd-values.yaml', + stringify({ vault: vaultValues, clusters }), + ) + if (!action) { + this.logger.verbose(`Zone argocd-values.yaml is up to date (zone=${zoneSlug})`) + return + } + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Update zone ${zoneSlug}`, [action]) + } + + private async generateZoneVaultValues(zoneSlug: string) { + const roleId = await this.vault.getAuthApproleRoleRoleId(`zone-${zoneSlug}`).catch(() => { + this.logger.warn(`Couldn't find zone app role (zone=${zoneSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(`zone-${zoneSlug}`).catch(() => { + this.logger.warn(`Couldn't generate zone app role secret (zone=${zoneSlug})`) + return undefined + }) + return generateZoneVaultValues(this.vaultConfig.url, zoneSlug, roleId, secretId) + } + @StartActiveSpan() private async cleanupProject(project: ProjectWithDetails) { const span = trace.getActiveSpan() diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts new file mode 100644 index 0000000000..d6fc2ec339 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -0,0 +1,33 @@ +import type { Kubeconfig } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateClusterTlsClientConfig(kubeconfig: Kubeconfig) { + return { + ...kubeconfig.user.keyData && { keyData: kubeconfig.user.keyData }, + ...kubeconfig.user.certData && { certData: kubeconfig.user.certData }, + ...kubeconfig.cluster.caData && !kubeconfig.cluster.skipTLSVerify && { caData: kubeconfig.cluster.caData }, + ...kubeconfig.cluster.skipTLSVerify && { insecure: kubeconfig.cluster.skipTLSVerify }, + serverName: kubeconfig.cluster.tlsServerName, + ...kubeconfig.user.username && { username: kubeconfig.user.username }, + ...kubeconfig.user.password && { password: kubeconfig.user.password }, + ...kubeconfig.user.token && { bearerToken: kubeconfig.user.token }, + } +} + +export function generateZoneVaultValues(vaultUrl: string, zoneSlug: string, roleId: string | undefined, secretId: string | undefined) { + return { + url: vaultUrl, + kvName: `zone-${zoneSlug}`, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } +} + +export function generateClusterSecretData(cluster: { label: string, clusterResources: boolean }, kubeconfig: Kubeconfig) { + return { + name: cluster.label, + clusterResources: String(cluster.clusterResources), + server: kubeconfig.cluster.server, + config: stringify({ tlsClientConfig: generateClusterTlsClientConfig(kubeconfig) }), + } +} diff --git a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts index facc294042..318001729f 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -1,3 +1,4 @@ +import type { CleanedCluster, ClusterDetails, CreateClusterBody } from '@cpn-console/shared' import type { Cluster, Kubeconfig, Stage } from '@prisma/client' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' import { faker } from '@faker-js/faker' @@ -76,7 +77,7 @@ export function makeKubeconfig(overrides: Partial = {}): Kubeconfig return { id: faker.string.uuid(), user: { - username: faker.internet.userName(), + username: faker.internet.username(), token: faker.string.alphanumeric(20), }, cluster: { @@ -88,3 +89,49 @@ export function makeKubeconfig(overrides: Partial = {}): Kubeconfig ...overrides, } satisfies Kubeconfig } + +export function makeContractCluster(overrides: Partial = {}): CleanedCluster { + return { + id: faker.string.uuid(), + label: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), + infos: faker.lorem.sentence(), + clusterResources: faker.datatype.boolean(), + privacy: faker.helpers.arrayElement(['public', 'dedicated'] as const), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid()], + cpu: faker.number.int({ min: 0, max: 64 }), + gpu: faker.number.int({ min: 0, max: 8 }), + memory: faker.number.int({ min: 0, max: 512 }), + ...overrides, + } satisfies CleanedCluster +} + +export function makeContractClusterDetails(overrides: Partial = {}): ClusterDetails { + return { + ...makeContractCluster(), + projectIds: [faker.string.uuid()], + kubeconfig: { + user: { username: faker.internet.username() }, + cluster: { tlsServerName: faker.internet.domainName() }, + }, + ...overrides, + } satisfies ClusterDetails +} + +export function makeCreateClusterBody(overrides: Partial = {}): CreateClusterBody { + const cluster = makeContractCluster() + return { + label: cluster.label, + infos: cluster.infos, + clusterResources: cluster.clusterResources, + privacy: cluster.privacy, + zoneId: cluster.zoneId, + stageIds: cluster.stageIds, + cpu: cluster.cpu, + gpu: cluster.gpu, + memory: cluster.memory, + projectIds: [faker.string.uuid()], + kubeconfig: { cluster: { tlsServerName: 'example.com' }, user: {} }, + ...overrides, + } satisfies CreateClusterBody +} diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts index 801d51aa93..a2793fe262 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -1,36 +1,49 @@ import type { TestingModule } from '@nestjs/testing' -import type { MockProxy } from 'vitest-mock-extended' import type { FastifyRequest } from 'fastify' +import type { MockProxy } from 'vitest-mock-extended' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { ADMIN_PERMS } from '@cpn-console/shared' import { faker } from '@faker-js/faker' +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 { makeClusterDetailsRecord, makeClusterListRecord } from './cluster-testing.utils' +import { AuthService } from '../infrastructure/auth/auth.service' +import { UserPermissionPolicy } from '../infrastructure/permission/user/user-policy.service' +import { UserPermissionService } from '../infrastructure/permission/user/user.service' +import { + makeClusterDetailsRecord, + makeClusterEnvironmentsRecord, + makeClusterListRecord, + makeCreateClusterBody, +} from './cluster-testing.utils' import { ClusterController } from './cluster.controller' import { ClusterService } from './cluster.service' +import { toClusterDetails, toClusters } from './cluster.utils' describe('clusterController', () => { let module: TestingModule let controller: ClusterController let service: MockProxy + let auth: MockProxy - const user = { userId: faker.string.uuid() } as UserContext - const request = { id: faker.string.uuid() } as FastifyRequest + const userId = faker.string.uuid() + const request = mock({ id: faker.string.uuid() }) + const user: UserContext = { userId, userType: 'human' } beforeEach(async () => { service = mock() + auth = mock() module = await Test.createTestingModule({ controllers: [ClusterController], providers: [ { provide: ClusterService, useValue: service }, + { provide: AuthService, useValue: auth }, + UserPermissionService, + UserPermissionPolicy, ], - }) - .overrideGuard(UserGuard) - .useValue({ canActivate: () => true }) - .compile() + }).compile() controller = module.get(ClusterController) }) @@ -39,51 +52,133 @@ describe('clusterController', () => { expect(controller).toBeDefined() }) - it('delegates list to the service', async () => { - const clusters = [makeClusterListRecord()] - service.listClusters.mockResolvedValue(clusters as never) + describe.each([ + { name: 'admin', adminPermissions: ADMIN_PERMS.MANAGE }, + { name: 'power-user', adminPermissions: ADMIN_PERMS.LIST_CLUSTERS }, + { name: 'plain user', adminPermissions: 0n }, + ])('gET /api/v1/clusters as $name', ({ name: _name, adminPermissions }) => { + const requestor: UserContext = { userId, adminPermissions, userType: 'human' } + + it('serves 200 without an admin-only 403 and passes the requestor to the userId filter', async () => { + const clusters = [makeClusterListRecord()] + service.listClustersForUser.mockResolvedValue(clusters) + auth.authenticate.mockResolvedValue(requestor) + + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + const response = await app.getHttpAdapter().getInstance().inject({ method: 'GET', url: '/api/v1/clusters' }) + await app.close() + + expect(response.statusCode).toBe(200) + expect(service.listClustersForUser).toHaveBeenCalledWith(requestor) + }) + }) + + it('maps raw list records to the contract shape', async () => { + const record = makeClusterListRecord({ infos: null }) + service.listClustersForUser.mockResolvedValue([record]) + + const result = await controller.list({ userId: faker.string.uuid(), adminPermissions: ADMIN_PERMS.LIST_CLUSTERS, userType: 'human' }) + + expect(result).toEqual([toClusters([record])[0]]) + expect(result[0].infos).toBe('') + expect(result[0].stageIds).toEqual([record.stages[0].id]) + }) + + it('maps cluster details to the contract shape', async () => { + const record = makeClusterDetailsRecord({ infos: null }) + service.getClusterDetailsRecord.mockResolvedValue(record) + + const result = await controller.getDetails(record.id) + + expect(result).toEqual(expect.objectContaining({ + id: record.id, + infos: '', + projectIds: [record.projects[0].id], + stageIds: [record.stages[0].id], + kubeconfig: { cluster: record.kubeconfig.cluster, user: record.kubeconfig.user }, + })) + }) + + it('maps cluster environments for the contract response', async () => { + const envs = [makeClusterEnvironmentsRecord(), makeClusterEnvironmentsRecord()] + service.getClusterAssociatedEnvironments.mockResolvedValue(envs) + + const result = await controller.getEnvironments(faker.string.uuid()) - expect(await controller.list()).toBe(clusters) - expect(service.listClusters).toHaveBeenCalledTimes(1) + expect(result).toEqual(envs.map(env => ({ + project: env.project.name, + name: env.name, + owner: env.project.owner.email, + cpu: env.cpu, + gpu: env.gpu, + memory: env.memory, + }))) }) it('delegates details, usage and environments with clusterId', async () => { const clusterId = faker.string.uuid() - const details = makeClusterDetailsRecord() - service.getClusterDetails.mockResolvedValue(details as never) - service.getClusterUsage.mockResolvedValue({} as never) + const record = makeClusterDetailsRecord() + const usage = { cpu: 1, gpu: 0, memory: 8 } + service.getClusterDetailsRecord.mockResolvedValue(record) + service.getClusterUsage.mockResolvedValue(usage) service.getClusterAssociatedEnvironments.mockResolvedValue([]) - expect(await controller.getDetails(clusterId)).toBe(details) - expect(service.getClusterDetails).toHaveBeenCalledWith(clusterId) - await controller.getUsage(clusterId) + expect(await controller.getDetails(clusterId)).toEqual(toClusterDetails(record)) + expect(service.getClusterDetailsRecord).toHaveBeenCalledWith(clusterId) + expect(await controller.getUsage(clusterId)).toBe(usage) expect(service.getClusterUsage).toHaveBeenCalledWith(clusterId) await controller.getEnvironments(clusterId) expect(service.getClusterAssociatedEnvironments).toHaveBeenCalledWith(clusterId) }) + it('serves the contract URL GET /api/v1/clusters/usage/:clusterId', async () => { + const clusterId = faker.string.uuid() + service.getClusterUsage.mockResolvedValue({ cpu: 1, gpu: 0, memory: 8 }) + service.getClusterDetailsRecord.mockResolvedValue(makeClusterDetailsRecord()) + auth.authenticate.mockResolvedValue({ userId, adminPermissions: ADMIN_PERMS.LIST_CLUSTERS, userType: 'human' }) + + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + const server = app.getHttpAdapter().getInstance() + + const usageResponse = await server.inject({ method: 'GET', url: `/api/v1/clusters/usage/${clusterId}` }) + expect(usageResponse.statusCode).toBe(200) + expect(usageResponse.json()).toEqual({ cpu: 1, gpu: 0, memory: 8 }) + expect(service.getClusterUsage).toHaveBeenCalledWith(clusterId) + + const detailsResponse = await server.inject({ method: 'GET', url: `/api/v1/clusters/${clusterId}` }) + expect(detailsResponse.statusCode).toBe(200) + expect(service.getClusterDetailsRecord).toHaveBeenCalledWith(clusterId) + + await app.close() + }) + it('delegates create with body, userId and requestId', async () => { - const details = makeClusterDetailsRecord() - service.createCluster.mockResolvedValue(details as never) + const body = makeCreateClusterBody() + const record = makeClusterDetailsRecord() + const details = toClusterDetails(record) + service.createCluster.mockResolvedValue(record) - expect(await controller.create(details as never, user, request)).toBe(details) - expect(service.createCluster).toHaveBeenCalledWith(details, user.userId, request.id) + expect(await controller.create(body, user, request)).toEqual(details) + expect(service.createCluster).toHaveBeenCalledWith(body, user.userId, request.id) }) it('delegates update with clusterId, body, userId and requestId', async () => { const clusterId = faker.string.uuid() - const details = makeClusterDetailsRecord() - service.updateCluster.mockResolvedValue(details as never) + const record = makeClusterDetailsRecord() + const details = toClusterDetails(record) + service.updateCluster.mockResolvedValue(record) - expect(await controller.update(clusterId, { label: 'new' } as never, user, request)).toBe(details) - expect(service.updateCluster).toHaveBeenCalledWith({ label: 'new' }, clusterId, user.userId, request.id) + expect(await controller.update(clusterId, { label: 'new-label' }, user, request)).toEqual(details) + expect(service.updateCluster).toHaveBeenCalledWith({ label: 'new-label' }, clusterId, user.userId, request.id) }) it('delegates delete with clusterId, userId, requestId and force', async () => { const clusterId = faker.string.uuid() - service.deleteCluster.mockResolvedValue(null) + service.deleteCluster.mockResolvedValue(0) - await controller.delete(clusterId, { force: true } as never, user, request) + expect(await controller.delete(clusterId, { force: true }, user, request)).toBeNull() expect(service.deleteCluster).toHaveBeenCalledWith({ clusterId, userId: user.userId, @@ -91,4 +186,11 @@ describe('clusterController', () => { force: true, }) }) + + it('formats the forced-environment message for the contract response', async () => { + service.deleteCluster.mockResolvedValue(3) + + expect(await controller.delete(faker.string.uuid(), { force: true }, user, request)) + .toBe('3 environnements supprimés de force, n\'oubliez pas de reprovisionner les projets concernés') + }) }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index ea81d3f88b..c74b7b7020 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -1,7 +1,7 @@ -import type { clusterContract, CreateClusterBody, DeleteClusterQuery, UpdateClusterBody } from '@cpn-console/shared' -import type { ClientInferResponseBody } from '@ts-rest/core' +import type { CleanedCluster, ClusterAssociatedEnvironments, ClusterDetails, ClusterUsage, CreateClusterBody, DeleteClusterQuery, UpdateClusterBody } from '@cpn-console/shared' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ClusterDetailsRecord } from './cluster-queries.utils' import { CreateClusterBodySchema, DeleteClusterQuerySchema, @@ -13,10 +13,7 @@ import { RequireAdminPermission } from '../infrastructure/permission/user/user-a import { UserGuard } from '../infrastructure/permission/user/user.guard' import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { ClusterService } from './cluster.service' - -type ClusterList = ClientInferResponseBody -type ClusterDetails = ClientInferResponseBody -type ClusterUsage = ClientInferResponseBody +import { toClusterAssociatedEnvironments, toClusterDetails, toClusters } from './cluster.utils' @Controller('api/v1/clusters') @UseGuards(UserGuard) @@ -24,18 +21,17 @@ export class ClusterController { constructor(@Inject(ClusterService) private readonly clusterService: ClusterService) {} @Get('') - @RequireAdminPermission('ListClusters') - list(): Promise { - return this.clusterService.listClusters() + async list(@AuthUser() user: UserContext): Promise { + return toClusters(await this.clusterService.listClustersForUser(user)) } @Get(':clusterId') @RequireAdminPermission('ListClusters') - getDetails(@Param('clusterId') clusterId: string): Promise { - return this.clusterService.getClusterDetails(clusterId) + async getDetails(@Param('clusterId') clusterId: string): Promise { + return toClusterDetails(await this.clusterService.getClusterDetailsRecord(clusterId)) } - @Get(':clusterId/usage') + @Get('usage/:clusterId') @RequireAdminPermission('ListClusters') getUsage(@Param('clusterId') clusterId: string): Promise { return this.clusterService.getClusterUsage(clusterId) @@ -43,47 +39,50 @@ export class ClusterController { @Get(':clusterId/environments') @RequireAdminPermission('ListClusters') - getEnvironments(@Param('clusterId') clusterId: string): Promise { - return this.clusterService.getClusterAssociatedEnvironments(clusterId) + async getEnvironments(@Param('clusterId') clusterId: string): Promise { + return toClusterAssociatedEnvironments(await this.clusterService.getClusterAssociatedEnvironments(clusterId)) } @Post('') @RequireAdminPermission('ManageClusters') @HttpCode(HttpStatus.CREATED) - create( + async create( @Body(new ZodValidationPipe(CreateClusterBodySchema)) data: CreateClusterBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { - return this.clusterService.createCluster(data, user.userId, request.id) + const record: ClusterDetailsRecord = await this.clusterService.createCluster(data, user.userId, request.id) + return toClusterDetails(record) } @Put(':clusterId') @RequireAdminPermission('ManageClusters') @HttpCode(HttpStatus.OK) - update( + async update( @Param('clusterId') clusterId: string, @Body(new ZodValidationPipe(UpdateClusterBodySchema)) data: UpdateClusterBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { - return this.clusterService.updateCluster(data, clusterId, user.userId, request.id) + const record = await this.clusterService.updateCluster(data, clusterId, user.userId, request.id) + return toClusterDetails(record) } @Delete(':clusterId') @RequireAdminPermission('ManageClusters') - @HttpCode(HttpStatus.NO_CONTENT) - delete( + async delete( @Param('clusterId') clusterId: string, @Query(new ZodValidationPipe(DeleteClusterQuerySchema)) { force }: DeleteClusterQuery, @AuthUser() user: UserContext, @Req() request: FastifyRequest, ): Promise { - return this.clusterService.deleteCluster({ + const forcedCount = await this.clusterService.deleteCluster({ clusterId, userId: user.userId, requestId: request.id, force, }) + if (!forcedCount) return null + return `${forcedCount} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` } } diff --git a/apps/server-nestjs/src/modules/cluster/cluster.module.ts b/apps/server-nestjs/src/modules/cluster/cluster.module.ts index f1a8caf58f..f28935eee0 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.module.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -1,21 +1,18 @@ import { Module } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' -import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' -import { LogModule } from '../log/log.module' import { ClusterController } from './cluster.controller' import { ClusterService } from './cluster.service' @Module({ imports: [ + AppEventsModule, AuthModule, - ConfigModule.forFeature(baseConfigFactory), DatabaseModule, EventsModule, - LogModule, UserPermissionModule, ], controllers: [ClusterController], diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts index 3c7c6f9c72..60787bd801 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -1,5 +1,6 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { UnprocessableEntityException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' @@ -8,89 +9,64 @@ import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { baseConfigFactory } from '../../config/base.config' import { makeEnvironment } from '../environment/environment-testing.utils' +import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' -import { LogService } from '../log/log.service' import { makeCluster, makeClusterDetailsRecord, - makeClusterEnvironmentsRecord, makeClusterListRecord, + makeCreateClusterBody, } from './cluster-testing.utils' import { ClusterService } from './cluster.service' describe('clusterService', () => { let service: ClusterService let prisma: DeepMockProxy - let logs: DeepMockProxy let events: DeepMockProxy let baseConfig: DeepMockProxy> + let appEvents: DeepMockProxy beforeEach(async () => { prisma = mockDeep() prisma.$transaction.mockImplementation(async (cb: (tx: unknown) => unknown) => cb(prisma)) - logs = mockDeep() events = mockDeep() baseConfig = mockDeep>() + appEvents = mockDeep() + appEvents.emitClusterEvent.mockResolvedValue({}) const moduleRef = await Test.createTestingModule({ providers: [ ClusterService, { provide: PrismaService, useValue: prisma }, - { provide: LogService, useValue: logs }, { provide: EventEmitter2, useValue: events }, { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: AppEventsService, useValue: appEvents }, ], }).compile() service = moduleRef.get(ClusterService) }) - it('lists clusters with stageIds and normalized infos', async () => { + it('lists raw cluster records for an admin', async () => { const record = makeClusterListRecord({ infos: null }) prisma.cluster.findMany.mockResolvedValue([record]) - const result = await service.listClusters() - - expect(result).toEqual([{ - id: record.id, - label: record.label, - infos: '', - clusterResources: record.clusterResources, - privacy: record.privacy, - zoneId: record.zoneId, - cpu: record.cpu, - gpu: record.gpu, - memory: record.memory, - stageIds: [record.stages[0].id], - }]) + const result = await service.listClustersForUser({ userId: 'admin-1', adminPermissions: ADMIN_PERMS.LIST_CLUSTERS }) + + expect(result).toEqual([record]) }) it('passes the authorized user filter when listing clusters', async () => { prisma.cluster.findMany.mockResolvedValue([]) const userId = faker.string.uuid() - await service.listClusters(userId) + await service.listClustersForUser({ userId }) expect(prisma.cluster.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { OR: expect.any(Array) }, })) }) - it('maps cluster details to the contract shape', async () => { - const record = makeClusterDetailsRecord({ infos: null }) - prisma.cluster.findUniqueOrThrow.mockResolvedValue(record) - - const result = await service.getClusterDetails(record.id) - - expect(result).toEqual(expect.objectContaining({ - id: record.id, - infos: '', - projectIds: [record.projects[0].id], - stageIds: [record.stages[0].id], - kubeconfig: { cluster: record.kubeconfig.cluster, user: record.kubeconfig.user }, - })) - }) - it('returns cluster usage from the aggregate', async () => { const usage = { cpu: 1, gpu: 0, memory: 8 } prisma.environment.aggregate.mockResolvedValue({ @@ -135,8 +111,27 @@ describe('clusterService', () => { expect(result.id).toEqual(details.id) expect(prisma.cluster.create).toHaveBeenCalled() expect(prisma.cluster.update).toHaveBeenCalled() - expect(events.emitAsync).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: cluster.id })) - expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Create Cluster' })) + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: cluster.id }), expect.any(Object), 'Echec des services à la création/mise à jour du cluster') + }) + + it('rejects cluster creation when a plugin reports KO', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.create.mockResolvedValue(makeCluster()) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(makeClusterDetailsRecord()) + appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) + + await expect( + service.createCluster( + makeCreateClusterBody({ + label: 'ko-cluster', + infos: '', + clusterResources: false, + privacy: 'public', + }), + faker.string.uuid(), + faker.string.uuid(), + ), + ).rejects.toThrow(UnprocessableEntityException) }) it('rejects cluster creation when the label is already taken', async () => { @@ -177,8 +172,7 @@ describe('clusterService', () => { expect(result.id).toEqual(record.id) expect(prisma.cluster.update).toHaveBeenCalled() - expect(events.emitAsync).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id })) - expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Update Cluster' })) + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id }), expect.any(Object), 'Echec des services à la création/mise à jour du cluster') }) it('rejects updating a missing cluster', async () => { @@ -189,21 +183,35 @@ describe('clusterService', () => { ).rejects.toThrow('Cluster not found') }) - it('deletes a cluster when no environments are deployed', async () => { + it('deletes a cluster after a successful hook, in the legacy order', async () => { const record = makeClusterListRecord() prisma.environment.findFirst.mockResolvedValue(null) prisma.cluster.delete.mockResolvedValue(record) - const message = await service.deleteCluster({ + const forcedCount = await service.deleteCluster({ clusterId: record.id, userId: faker.string.uuid(), requestId: faker.string.uuid(), }) - expect(message).toBeNull() - expect(prisma.cluster.delete).toHaveBeenCalledWith({ where: { id: record.id } }) - expect(events.emitAsync).toHaveBeenCalledWith('cluster.delete', expect.objectContaining({ clusterId: record.id })) - expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ action: 'Delete Cluster' })) + expect(forcedCount).toBe(0) + expect(appEvents.emitClusterEvent).toHaveBeenCalledBefore(prisma.cluster.delete) + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.delete', expect.objectContaining({ clusterId: record.id }), expect.any(Object), 'Echec des services à la suppression du cluster') + }) + + it('rejects cluster deletion and keeps the row when a plugin reports KO', async () => { + const record = makeClusterListRecord() + prisma.environment.findFirst.mockResolvedValue(null) + appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la suppression du cluster')) + + await expect( + service.deleteCluster({ + clusterId: record.id, + userId: faker.string.uuid(), + requestId: faker.string.uuid(), + }), + ).rejects.toThrow(UnprocessableEntityException) + expect(prisma.cluster.delete).not.toHaveBeenCalled() }) it('rejects cluster deletion when environments are deployed', async () => { @@ -218,29 +226,21 @@ describe('clusterService', () => { ).rejects.toThrow('Impossible de supprimer le cluster') }) - it('maps cluster environments for the contract response', async () => { - const envs = [makeClusterEnvironmentsRecord(), makeClusterEnvironmentsRecord()] - prisma.environment.findMany.mockResolvedValue(envs) - - const result = await service.getClusterAssociatedEnvironments(faker.string.uuid()) - - expect(result).toEqual(envs.map(env => ({ - project: env.project.name, - name: env.name, - owner: env.project.owner.email, - cpu: env.cpu, - gpu: env.gpu, - memory: env.memory, - }))) - }) - it('propagates upsert hook failure as 422', async () => { const record = makeClusterDetailsRecord() - prisma.cluster.findUnique.mockResolvedValue(record as never) - prisma.cluster.update.mockResolvedValue(record as never) - prisma.zone.update.mockResolvedValue(record as never) - prisma.cluster.findUniqueOrThrow.mockResolvedValue({ projects: [] } as never) - events.emitAsync.mockRejectedValue(new Error('hook down')) + prisma.cluster.findUnique.mockResolvedValue(record) + prisma.cluster.update.mockResolvedValue(record) + prisma.zone.update.mockResolvedValue({ + id: faker.string.uuid(), + slug: 'tz', + label: 'test-zone', + description: null, + createdAt: new Date(), + updatedAt: new Date(), + argocdUrl: 'https://example.com', + }) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(makeCluster()) + appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) await expect(service.updateCluster({ infos: 'x' }, record.id, 'u', 'r')) .rejects.toThrow(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 2c497f1b0d..f73261f125 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -1,18 +1,15 @@ import type { - CleanedCluster, - ClusterAssociatedEnvironments, - ClusterDetails, ClusterUsage, CreateClusterBody, UpdateClusterBody, } from '@cpn-console/shared' -import type { ConfigType } from '@nestjs/config' -import { ClusterPrivacySchema, KubeconfigSchema } from '@cpn-console/shared' -import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException, UnprocessableEntityException } from '@nestjs/common' -import { EventEmitter2 } from '@nestjs/event-emitter' -import { baseConfigFactory } from '../../config/base.config' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' +import { AdminAuthorized, ClusterPrivacySchema } from '@cpn-console/shared' +import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' -import { LogService } from '../log/log.service' import { createCluster as createClusterQuery, deleteCluster as deleteClusterQuery, @@ -38,60 +35,37 @@ const CLUSTER_DEDICATED = ClusterPrivacySchema.enum.dedicated @Injectable() export class ClusterService { - private readonly logger = new Logger(ClusterService.name) - constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, - @Inject(LogService) private readonly logs: LogService, - @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, ) {} - async listClusters(userId?: string): Promise { + async listClustersForUser(user: UserContext): Promise { + return this.listClusters(AdminAuthorized.ListClusters(user.adminPermissions) ? undefined : user.userId) + } + + private async listClusters(userId?: string): Promise { const where = listClustersWhere(userId) - const clusters = await listClustersQuery(this.prisma, where) - return clusters.map(({ stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster }) => ({ - ...cluster, - infos: infos ?? '', - stageIds: stages.map(({ id }) => id), - })) + return listClustersQuery(this.prisma, where) } - async getClusterDetails(clusterId: string): Promise { - const { infos, projects, stages, kubeconfig, secretName, kubeConfigId, createdAt, updatedAt, ...details } = await getClusterDetailsQuery(this.prisma, clusterId) - return { - ...details, - infos: infos ?? '', - projectIds: projects.map(project => project.id), - stageIds: stages.map(({ id }) => id), - kubeconfig: { - cluster: KubeconfigSchema.shape.cluster.passthrough().parse(kubeconfig.cluster), - user: KubeconfigSchema.shape.user.passthrough().parse(kubeconfig.user), - }, - } + async getClusterDetailsRecord(clusterId: string): Promise { + return getClusterDetailsQuery(this.prisma, clusterId) } async getClusterUsage(clusterId: string): Promise { return getClusterUsage(this.prisma, clusterId) } - async getClusterAssociatedEnvironments(clusterId: string): Promise { - const clusterEnvironments = await getClusterEnvironments(this.prisma, clusterId) - return clusterEnvironments.map(environment => ({ - project: environment.project?.name, - name: environment.name, - owner: environment.project?.owner.email, - cpu: environment.cpu, - gpu: environment.gpu, - memory: environment.memory, - })) + async getClusterAssociatedEnvironments(clusterId: string): Promise { + return getClusterEnvironments(this.prisma, clusterId) } async createCluster( data: CreateClusterBody, userId: string, requestId: string, - ): Promise { + ): Promise { const isLabelTaken = await getClusterByLabel(this.prisma, data.label) if (isLabelTaken) throw new ConflictException('Ce label existe déjà pour un autre cluster') @@ -111,15 +85,13 @@ export class ClusterService { return clusterCreated }) - await this.upsertClusterHook(clusterCreated.id, zoneId) - await this.logs.addLog({ + await this.appEvents.emitClusterEvent('cluster.upsert', { clusterId: clusterCreated.id, zoneId }, { action: 'Create Cluster', - data: { clusterId: clusterCreated.id, zoneId }, userId, requestId, - }) + }, 'Echec des services à la création/mise à jour du cluster') - return this.getClusterDetails(clusterCreated.id) + return this.getClusterDetailsRecord(clusterCreated.id) } async updateCluster( @@ -127,7 +99,7 @@ export class ClusterService { clusterId: string, userId: string, requestId: string, - ): Promise { + ): Promise { if (data?.privacy === CLUSTER_PUBLIC) delete data.projectIds const dbCluster = await getClusterById(this.prisma, clusterId) @@ -142,46 +114,17 @@ export class ClusterService { await linkZoneToClusters(tx, zoneId, [clusterId]) } - const dbProjects = await getProjectsByClusterId(tx, clusterId) - - let projectsToRemove: string[] = [] - - if (projectIds && clusterUpdated.privacy === CLUSTER_PUBLIC) { - projectsToRemove = dbProjects?.map(project => project.id) ?? [] - } else if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { - await linkClusterToProjects(tx, clusterId, projectIds) - projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] - } else if (clusterUpdated.privacy === CLUSTER_PUBLIC) { - projectsToRemove = dbProjects?.map(project => project.id) ?? [] - } - - for (const projectId of projectsToRemove) { - await removeClusterFromProject(tx, clusterUpdated.id, projectId) - } - - if (stageIds) { - await linkClusterToStages(tx, clusterId, stageIds) - - const dbStages = await listStagesByClusterId(tx, clusterId) - if (dbStages) { - for (const stage of dbStages) { - if (!stageIds.includes(stage.id)) { - await removeClusterFromStage(tx, clusterUpdated.id, stage.id) - } - } - } - } + await syncClusterProjectLinks(tx, clusterUpdated, clusterId, projectIds) + await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) }) - await this.upsertClusterHook(clusterId, dbCluster.zoneId) - await this.logs.addLog({ + await this.appEvents.emitClusterEvent('cluster.upsert', { clusterId, zoneId: dbCluster.zoneId }, { action: 'Update Cluster', - data: { clusterId, zoneId: dbCluster.zoneId }, userId, requestId, - }) + }, 'Echec des services à la création/mise à jour du cluster') - return this.getClusterDetails(clusterId) + return this.getClusterDetailsRecord(clusterId) } async deleteCluster({ @@ -194,47 +137,74 @@ export class ClusterService { userId?: string requestId: string force?: boolean - }): Promise { - let message: string | null = null - await this.prisma.$transaction(async (tx) => { - if (force) { - const envs = await tx.environment.deleteMany({ - where: { clusterId }, - }) - message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` - } else { - const environment = await tx.environment.findFirst({ where: { clusterId } }) - if (environment) throw new BadRequestException('Impossible de supprimer le cluster, des environnements en activité y sont déployés') - } - - await deleteClusterQuery(tx, clusterId) - }) - - await this.deleteClusterHook(clusterId) - await this.logs.addLog({ + }): Promise { + const environment = await this.prisma.environment.findFirst({ where: { clusterId } }) + if (!force && environment) throw new BadRequestException('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + // Legacy criterion: the external cleanup decides success. The cluster row + // and its (forced) environments only disappear once every plugin reported + // OK — a KO leaves everything replayable instead of a 204 with a dangling + // cluster. + await this.appEvents.emitClusterEvent('cluster.delete', { clusterId }, { action: 'Delete Cluster', - data: { clusterId }, userId, requestId, - }) + }, 'Echec des services à la suppression du cluster') - return message + let forcedCount = 0 + if (force && environment) { + const envs = await this.prisma.environment.deleteMany({ where: { clusterId } }) + forcedCount = envs.count + } + await deleteClusterQuery(this.prisma, clusterId) + return forcedCount } +} + +function projectsToRemoveFrom(clusterPrivacy: typeof CLUSTER_PUBLIC | typeof CLUSTER_DEDICATED, projectIds: string[] | undefined, dbProjectIds: string[]): string[] { + const keepDedicated = clusterPrivacy === CLUSTER_DEDICATED && projectIds + ? projectIds + : [] + return dbProjectIds.filter(dbProjectId => !keepDedicated.includes(dbProjectId)) +} - private async upsertClusterHook(clusterId: string, zoneId: string): Promise { - try { - await this.eventEmitter.emitAsync('cluster.upsert', { clusterId, zoneId }) - } catch (error) { - this.logger.error(`cluster.upsert hook failed (clusterId=${clusterId})`, error instanceof Error ? error.stack : String(error)) - throw new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster') +async function syncClusterProjectLinks( + tx: Prisma.TransactionClient, + clusterUpdated: Awaited>, + clusterId: string, + projectIds: string[] | undefined, +) { + if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { + await linkClusterToProjects(tx, clusterId, projectIds) + } + + if (clusterUpdated.privacy === CLUSTER_PUBLIC) { + const dbProjects = await getProjectsByClusterId(tx, clusterId) + for (const projectId of dbProjects?.map(project => project.id) ?? []) { + await removeClusterFromProject(tx, clusterUpdated.id, projectId) } + return + } + + const dbProjects = await getProjectsByClusterId(tx, clusterId) + for (const projectId of projectsToRemoveFrom(clusterUpdated.privacy, projectIds, dbProjects?.map(project => project.id) ?? [])) { + await removeClusterFromProject(tx, clusterUpdated.id, projectId) } +} + +async function syncClusterStageLinks( + tx: Prisma.TransactionClient, + clusterUpdated: Awaited>, + clusterId: string, + stageIds: string[] | undefined, +) { + if (!stageIds) return + + await linkClusterToStages(tx, clusterId, stageIds) - private async deleteClusterHook(clusterId: string): Promise { - try { - await this.eventEmitter.emitAsync('cluster.delete', { clusterId }) - } catch (error) { - this.logger.error(`cluster.delete hook failed (clusterId=${clusterId})`, error instanceof Error ? error.stack : String(error)) + const dbStages = await listStagesByClusterId(tx, clusterId) + for (const stage of dbStages ?? []) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(tx, clusterUpdated.id, stage.id) } } } diff --git a/apps/server-nestjs/src/modules/cluster/cluster.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster.utils.ts new file mode 100644 index 0000000000..e7ffb2af3b --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.utils.ts @@ -0,0 +1,41 @@ +import type { CleanedCluster, ClusterAssociatedEnvironments, ClusterDetails } from '@cpn-console/shared' +import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' +import { KubeconfigSchema } from '@cpn-console/shared' + +export function toCluster(record: ClusterListRecord): CleanedCluster { + const { stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster } = record + return { + ...cluster, + infos: infos ?? '', + stageIds: stages.map(({ id }) => id), + } +} + +export function toClusters(records: ClusterListRecord[]): CleanedCluster[] { + return records.map(toCluster) +} + +export function toClusterDetails(record: ClusterDetailsRecord): ClusterDetails { + const { infos, projects, stages, kubeconfig, secretName, kubeConfigId, createdAt, updatedAt, ...details } = record + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: KubeconfigSchema.shape.cluster.passthrough().parse(kubeconfig.cluster), + user: KubeconfigSchema.shape.user.passthrough().parse(kubeconfig.user), + }, + } +} + +export function toClusterAssociatedEnvironments(records: ClusterEnvironmentsRecord[]): ClusterAssociatedEnvironments { + return records.map(environment => ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project?.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + })) +} diff --git a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts index 91b2c8c4ea..ba9b270fbd 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -43,9 +43,7 @@ describe('environmentService', () => { autosync: true, } satisfies CreateEnvironment - const failedReconciliation = { - gitlab: { status: 'KO', message: 'Unable to provision environment', executionTime: 1, error: new Error('boom') }, - } as const + const failedReconciliation = new InternalServerErrorException('Echec des services') const validUpdateEnvironment = { cpu: 4, @@ -112,7 +110,7 @@ describe('environmentService', () => { action: 'Create Environment', userId, requestId, - }) + }, expect.any(String)) expect(result).toEqual(environment) }) @@ -120,7 +118,7 @@ describe('environmentService', () => { const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) validation.validateCreate.mockResolvedValue(undefined) datastore.createEnvironment.mockResolvedValue(environment) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) .rejects.toThrow(InternalServerErrorException) @@ -154,7 +152,7 @@ describe('environmentService', () => { action: 'Update Environment', userId, requestId, - }) + }, expect.any(String)) expect(result).toEqual(updated) }) @@ -163,7 +161,7 @@ describe('environmentService', () => { datastore.getProjectEnvironment.mockResolvedValue(existing) validation.validateUpdate.mockResolvedValue(undefined) datastore.updateEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment })) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) .rejects.toThrow(InternalServerErrorException) @@ -204,13 +202,13 @@ describe('environmentService', () => { action: 'Delete Environment', userId, requestId, - }) + }, expect.any(String)) }) it('should wait for the reconciliation and reject when a service fails', async () => { datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) .rejects.toThrow(InternalServerErrorException) diff --git a/apps/server-nestjs/src/modules/environment/environment.service.ts b/apps/server-nestjs/src/modules/environment/environment.service.ts index 85de7b3ffe..947c8f96c7 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -5,11 +5,9 @@ import type { EnvironmentWithCluster, EnvironmentWithDeploymentsCount } from './ import { Inject, Injectable, - InternalServerErrorException, NotFoundException, } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' -import { getFailedPlugins } from '../plugin/plugin.utils' import { EnvironmentDatastoreService } from './environment-datastore.service' import { EnvironmentValidationService } from './environment-validation.service' @@ -43,7 +41,7 @@ export class EnvironmentService { autosync: environmentToCreate.autosync, }) - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Create Environment', userId, @@ -64,7 +62,7 @@ export class EnvironmentService { autosync: environmentToUpdate.autosync, }) - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Update Environment', userId, @@ -77,7 +75,7 @@ export class EnvironmentService { async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { await this.getProjectEnvironmentOrThrow(projectId, environmentId) await this.environmentDatastoreService.deleteEnvironment(environmentId) - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Delete Environment', userId, @@ -101,17 +99,13 @@ export class EnvironmentService { * with a success on a project that AppEventsService just marked `failed`. The row * change stays committed — the reconciliation is replayable. */ - private async reconcileProjectAndThrowOnFailure( + private async reconcileProject( projectId: string, action: EventLogAction, userId: string, requestId: string, failureMessage: string, ): Promise { - const results = await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) - - if (getFailedPlugins(results).length) { - throw new InternalServerErrorException(failureMessage) - } + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }, failureMessage) } } diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 32e1e6d612..4823927f40 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -1,7 +1,7 @@ import type { ConfigType } from '@nestjs/config' import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' -import { Inject, Injectable, Logger } from '@nestjs/common' +import { Inject, Injectable, Logger, UnprocessableEntityException } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -47,6 +47,15 @@ export interface AdminRoleEventPayload { members: AdminRoleEventMember[] } +export type ClusterEventName = 'cluster.upsert' | 'cluster.delete' + +/** `zoneId` is the zone the cluster belonged to BEFORE the change (legacy hook semantics). */ +export interface ClusterEventPayload { + clusterId: string + zoneId?: string +} + + /** Admin-log action labels (legacy hooks wording). */ export type EventLogAction = | 'Create Project' | 'Update Project' | 'Delete all project resources' @@ -93,6 +102,7 @@ export class AppEventsService { event: ProjectEventName, projectOrId: string | ProjectWithDetails, context: EventContext, + failureMessage?: string, ): Promise { const project = typeof projectOrId === 'string' ? await getProject(this.prisma, projectOrId) @@ -105,6 +115,7 @@ export class AppEventsService { const results = await this.emitAndLog(event, project, project.id, context) await this.updateProjectStatus(event, project.id, results) + this.throwOnPluginFailure(event, results, failureMessage) return results } @@ -112,8 +123,9 @@ export class AppEventsService { event: ProjectMemberEventName, payload: ProjectMemberEventPayload, context: EventContext, + failureMessage?: string, ): Promise { - return this.emitAndLog(event, payload, payload.projectId, context) + return this.emitAndLog(event, payload, payload.projectId, context, failureMessage) } /** @@ -125,8 +137,23 @@ export class AppEventsService { event: RepositoryEventName, payload: RepositorySyncEventPayload, context: EventContext, + failureMessage?: string, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context, failureMessage) + } + + /** + * Emits a cluster event. Legacy hook parity: pass a failure message so a plugin + * KO surfaces as a 422, and only emit the delete after every plugin cleaned up + * successfully (the caller must not have removed the row yet). + */ + async emitClusterEvent( + event: ClusterEventName, + payload: ClusterEventPayload, + context: EventContext, + failureMessage?: string, ): Promise { - return this.emitAndLog(event, payload, payload.projectId, context) + return this.emitAndLog(event, payload, null, context, failureMessage) } // Emits a zone event. Zones have no project row: the log carries no project id @@ -145,6 +172,7 @@ export class AppEventsService { payload: unknown, projectId: string | null, context: EventContext, + failureMessage?: string, ): Promise { const start = process.hrtime.bigint() const responses = await this.eventEmitter.emitAsync(event, payload) @@ -161,9 +189,19 @@ export class AppEventsService { projectId, }) + this.throwOnPluginFailure(event, results, failureMessage) return results } + /** Legacy hooks parity: a plugin KO surfaces as a 422 on the caller's request. */ + private throwOnPluginFailure(event: string, results: PluginResults, failureMessage?: string): void { + const failed = getFailedPlugins(results) + if (failureMessage && failed.length) { + this.logger.error(`${event} failed (failed=${failed.join(',')})`) + throw new UnprocessableEntityException(failureMessage) + } + } + /** * Reflects the listeners' outcome on the project row (legacy hooks behavior): * any KO result marks the project `failed`; a fully successful upsert marks it diff --git a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts index ac1db17251..5155d1c0a1 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts @@ -68,9 +68,7 @@ describe('repositoryService', () => { service = module.get(RepositoryService) }) - const failedReconciliation = { - gitlab: { status: 'KO', message: 'Unable to provision repository', executionTime: 1, error: new Error('boom') }, - } as const + const failedReconciliation = new UnprocessableEntityException('Echec des services') it('should be defined', () => { expect(service).toBeDefined() @@ -119,7 +117,7 @@ describe('repositoryService', () => { action: 'Create Repository', userId, requestId, - }) + }, expect.any(String)) expect(result).toEqual(repository) }) @@ -136,6 +134,7 @@ describe('repositoryService', () => { 'repository.sync', expect.objectContaining({ internalRepoName: repository.internalRepoName, syncAllBranches: true }), expect.objectContaining({ action: 'Sync Repository' }), + expect.any(String), ) }) @@ -169,7 +168,7 @@ describe('repositoryService', () => { await service.createRepository(projectId, projectSlug, publicRepository, userId, requestId) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' }), expect.any(String)) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { @@ -177,7 +176,7 @@ describe('repositoryService', () => { datastore.hasRepositoryWithName.mockResolvedValue(false) datastore.createRepository.mockResolvedValue(repository) vault.writeGitlabMirrorCreds.mockResolvedValue(undefined) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.createRepository(projectId, projectSlug, validCreateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -194,9 +193,7 @@ describe('repositoryService', () => { datastore.createRepository.mockResolvedValue(repository) vault.writeGitlabMirrorCreds.mockResolvedValue(undefined) appEvents.emitProjectEvent.mockResolvedValue({}) - appEvents.emitRepositoryEvent.mockResolvedValue({ - gitlab: { status: 'KO', message: 'Unable to find mirror repository', executionTime: 1, error: new Error('boom') }, - }) + appEvents.emitRepositoryEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la synchronisation du dépôt')) await expect(service.createRepository(projectId, projectSlug, validCreateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -246,7 +243,7 @@ describe('repositoryService', () => { action: 'Update Repository', userId, requestId, - }) + }, expect.any(String)) expect(result).toEqual(updated) }) @@ -261,7 +258,7 @@ describe('repositoryService', () => { expect(vault.deleteGitlabMirrorCreds).toHaveBeenCalledWith(projectSlug, updated.internalRepoName) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) }) it('leaves Vault untouched when the update carries no credential change', async () => { @@ -274,14 +271,14 @@ describe('repositoryService', () => { expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() expect(vault.deleteGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { const repository = makeRepository({ id: repositoryId, projectId }) datastore.getRepositoryById.mockResolvedValue(repository) datastore.updateRepository.mockResolvedValue(repository) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.updateRepository(projectId, projectSlug, repositoryId, validUpdateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -320,6 +317,7 @@ describe('repositoryService', () => { syncAllBranches: true, }, { action: 'Sync Repository', userId, requestId }, + expect.any(String), ) expect(datastore.updateBranchName).not.toHaveBeenCalled() }) @@ -346,6 +344,7 @@ describe('repositoryService', () => { 'repository.sync', expect.objectContaining({ syncAllBranches: false, branchName }), expect.objectContaining({ action: 'Sync Repository' }), + expect.any(String), ) }) @@ -354,9 +353,7 @@ describe('repositoryService', () => { const repository = makeRepository({ id: repositoryId, projectId }) datastore.getRepositoryById.mockResolvedValue(repository) datastore.updateBranchName.mockResolvedValue(repository) - appEvents.emitRepositoryEvent.mockResolvedValue({ - gitlab: { status: 'KO', message: 'Unable to find mirror repository', executionTime: 1, error: new Error('boom') }, - }) + appEvents.emitRepositoryEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la synchronisation du dépôt')) await expect(service.syncRepository(projectId, projectSlug, repositoryId, { syncAllBranches: false, branchName }, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -386,13 +383,13 @@ describe('repositoryService', () => { action: 'Delete Repository', userId, requestId, - }) + }, expect.any(String)) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { datastore.getRepositoryById.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) datastore.deleteRepository.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) - appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) + appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) await expect(service.deleteRepository(projectId, repositoryId, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -434,7 +431,7 @@ describe('repositoryService', () => { expect(result).toEqual(repository) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' }), expect.any(String)) }) it('updates a repository without applying the credential intent', async () => { @@ -448,7 +445,7 @@ describe('repositoryService', () => { expect(result).toEqual(updated) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() expect(vault.deleteGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) }) it('deletes a repository', async () => { @@ -459,7 +456,7 @@ describe('repositoryService', () => { await vaultlessService.deleteRepository(projectId, repositoryId, userId, requestId) expect(datastore.deleteRepository).toHaveBeenCalledWith(repositoryId) - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Delete Repository' })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Delete Repository' }), expect.any(String)) }) }) }) diff --git a/apps/server-nestjs/src/modules/repository/repository.service.ts b/apps/server-nestjs/src/modules/repository/repository.service.ts index c1f5bcbd06..08bce2605f 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.ts @@ -3,9 +3,8 @@ import type { Repository } from '@prisma/client' import type { EventLogAction, RepositorySyncEventPayload } from '../events/app-events.service' import type { PluginResults } from '../plugin/plugin.utils' import type { RepositoryMirrorCredentialUpdate } from './repository.utils' -import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Optional, UnprocessableEntityException } from '@nestjs/common' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' -import { getFailedPlugins } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { RepositoryDatastoreService } from './repository-datastore.service' import { buildRepositoryCreateData, buildRepositoryUpdateData, parseRepositoryCredentialUpdate } from './repository.utils' @@ -49,7 +48,7 @@ export class RepositoryService { } } - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Create Repository', userId, @@ -69,8 +68,6 @@ export class RepositoryService { ) if (!Object.keys(results).length) { this.logger.warn(`repository.sync after creation had no listener (repositoryId=${repository.id}): no sync plugin is enabled`) - } else if (getFailedPlugins(results).length) { - throw new UnprocessableEntityException('Echec des services à la synchronisation du dépôt') } } @@ -89,7 +86,7 @@ export class RepositoryService { await this.repositoryDatastoreService.updateBranchName(repositoryId, syncRequest.branchName) } - const results = await this.syncRepositoryMirror( + await this.syncRepositoryMirror( { projectId, projectSlug, @@ -101,10 +98,6 @@ export class RepositoryService { userId, requestId, ) - - if (getFailedPlugins(results).length) { - throw new UnprocessableEntityException('Echec des services à la synchronisation du dépôt') - } } private syncRepositoryMirror(payload: RepositorySyncEventPayload, userId: string, requestId: string): Promise { @@ -112,7 +105,7 @@ export class RepositoryService { action: 'Sync Repository', userId, requestId, - }) + }, 'Echec des services à la synchronisation du dépôt') } async updateRepository(projectId: string, projectSlug: string, repositoryId: string, repositoryToUpdate: UpdateRepository, userId: string, requestId: string): Promise { @@ -129,7 +122,7 @@ export class RepositoryService { // removal) must be persisted first. await this.applyMirrorCredentialUpdate(projectSlug, repository, parseRepositoryCredentialUpdate(repositoryToUpdate)) - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Update Repository', userId, @@ -171,7 +164,7 @@ export class RepositoryService { async deleteRepository(projectId: string, repositoryId: string, userId: string, requestId: string): Promise { await this.getProjectRepositoryOrThrow(projectId, repositoryId) await this.repositoryDatastoreService.deleteRepository(repositoryId) - await this.reconcileProjectAndThrowOnFailure( + await this.reconcileProject( projectId, 'Delete Repository', userId, @@ -196,17 +189,13 @@ export class RepositoryService { * success on a project that AppEventsService just marked `failed`. The row change * stays committed — the reconciliation is replayable. */ - private async reconcileProjectAndThrowOnFailure( + private async reconcileProject( projectId: string, action: EventLogAction, userId: string, requestId: string, failureMessage: string, ): Promise { - const results = await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) - - if (getFailedPlugins(results).length) { - throw new UnprocessableEntityException(failureMessage) - } + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }, failureMessage) } } diff --git a/packages/shared/src/contracts/cluster.ts b/packages/shared/src/contracts/cluster.ts index 14db5a3790..a17c2cb886 100644 --- a/packages/shared/src/contracts/cluster.ts +++ b/packages/shared/src/contracts/cluster.ts @@ -135,3 +135,4 @@ export const clusterContract = contractInstance.router({ }) export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type ClusterList = ClientInferResponseBody From f37e5de4dc7a5a6e057fd0107ef381f8f5e8f686 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:06:22 +0200 Subject: [PATCH 13/22] fix(argocd): align vault secret-id helper with renamed ensure method Signed-off-by: William Phetsinorath Change-Id: Id86ef6af532de064301d2586c163a7326a6a6964 Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- apps/server-nestjs/src/modules/argocd/argocd.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index c4c1dc4cbf..dd0001fe91 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -140,7 +140,7 @@ export class ArgoCDService { this.logger.warn(`Couldn't find zone app role (zone=${zoneSlug})`) return undefined }) - const secretId = await this.vault.createAuthApproleRoleSecretId(`zone-${zoneSlug}`).catch(() => { + const secretId = await this.vault.ensureAuthApproleRoleSecretId(`zone-${zoneSlug}`).catch(() => { this.logger.warn(`Couldn't generate zone app role secret (zone=${zoneSlug})`) return undefined }) From 662c7ef14b121cea70917c17bb078771232a5035 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:26:34 +0200 Subject: [PATCH 14/22] refactor(events): return failed-event handling to calling services Signed-off-by: William Phetsinorath Change-Id: I5cafffa7f7e9786916555e0e69894d6b6a6a6964 Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Signed-off-by: William Phetsinorath Change-Id: I0719ab0da6e2d14f052da99641f0980c6a6a6964 --- .../src/modules/cluster/cluster-queries.utils.ts | 2 +- .../src/modules/cluster/cluster.service.spec.ts | 8 ++++---- apps/server-nestjs/src/modules/cluster/cluster.service.ts | 4 ++-- .../src/modules/events/app-events.service.ts | 8 ++++---- apps/server-nestjs/src/modules/events/app-events.utils.ts | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index fbdb6e3795..4035dae177 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -97,7 +97,7 @@ export function listClusters(prisma: Prisma.TransactionClient, where: Prisma.Clu }) } -export function listClustersWhere(userId?: string): Prisma.ClusterWhereInput { +export function generateClusterWhereInput(userId?: string): Prisma.ClusterWhereInput { return userId ? { OR: [ diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts index 60787bd801..8a48cc6f3b 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -82,7 +82,7 @@ describe('clusterService', () => { expect(result).toEqual(usage) }) - it('creates a cluster, links projects and stages, and emits the hook', async () => { + it('creates a cluster, links projects and stages, and emits the cluster event', async () => { const record = makeClusterListRecord() const cluster = makeCluster() const details = makeClusterDetailsRecord() @@ -157,7 +157,7 @@ describe('clusterService', () => { ).rejects.toThrow('Ce label existe déjà') }) - it('updates cluster fields and emits the hook', async () => { + it('updates cluster fields and emits the cluster event', async () => { const record = makeClusterDetailsRecord() prisma.cluster.findUnique.mockResolvedValue(record) prisma.cluster.update.mockResolvedValue(record) @@ -183,7 +183,7 @@ describe('clusterService', () => { ).rejects.toThrow('Cluster not found') }) - it('deletes a cluster after a successful hook, in the legacy order', async () => { + it('deletes a cluster after a successful reconcile, in the legacy order', async () => { const record = makeClusterListRecord() prisma.environment.findFirst.mockResolvedValue(null) prisma.cluster.delete.mockResolvedValue(record) @@ -226,7 +226,7 @@ describe('clusterService', () => { ).rejects.toThrow('Impossible de supprimer le cluster') }) - it('propagates upsert hook failure as 422', async () => { + it('propagates upsert reconcile failure as 422', async () => { const record = makeClusterDetailsRecord() prisma.cluster.findUnique.mockResolvedValue(record) prisma.cluster.update.mockResolvedValue(record) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index f73261f125..8df5f40d4a 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -23,7 +23,7 @@ import { linkClusterToStages, linkZoneToClusters, listClusters as listClustersQuery, - listClustersWhere, + generateClusterWhereInput, listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, @@ -45,7 +45,7 @@ export class ClusterService { } private async listClusters(userId?: string): Promise { - const where = listClustersWhere(userId) + const where = generateClusterWhereInput(userId) return listClustersQuery(this.prisma, where) } diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 4823927f40..eb5ffb05c4 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -49,7 +49,7 @@ export interface AdminRoleEventPayload { export type ClusterEventName = 'cluster.upsert' | 'cluster.delete' -/** `zoneId` is the zone the cluster belonged to BEFORE the change (legacy hook semantics). */ +/** `zoneId` is the zone the cluster belonged to BEFORE the change. */ export interface ClusterEventPayload { clusterId: string zoneId?: string @@ -143,7 +143,7 @@ export class AppEventsService { } /** - * Emits a cluster event. Legacy hook parity: pass a failure message so a plugin + * Emits a cluster event. Parity with the legacy server: pass a failure message so a listener * KO surfaces as a 422, and only emit the delete after every plugin cleaned up * successfully (the caller must not have removed the row yet). */ @@ -193,7 +193,7 @@ export class AppEventsService { return results } - /** Legacy hooks parity: a plugin KO surfaces as a 422 on the caller's request. */ + /** A listener KO surfaces as a 422 on the caller's request. */ private throwOnPluginFailure(event: string, results: PluginResults, failureMessage?: string): void { const failed = getFailedPlugins(results) if (failureMessage && failed.length) { @@ -203,7 +203,7 @@ export class AppEventsService { } /** - * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * Reflects the listeners' outcome on the project row: * any KO result marks the project `failed`; a fully successful upsert marks it * `created` and records the provisioning version. A successful `project.delete` * leaves the `archived` status set when the project was archived. diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index a9cf62706b..c3e3f9b95d 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -3,7 +3,7 @@ import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.u import { getErrorHttpDetails } from '../../utils/http.utils' import { getFailedPlugins } from '../plugin/plugin.utils' -/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +/** Per-service result as persisted in the admin logs (parsed by LogSchema). */ export interface LoggablePluginResult { status: { result: 'OK' | 'KO' From b4c16d4003b46cdcdfba20b68d803e8eb7879905 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:46:21 +0200 Subject: [PATCH 15/22] refactor(cluster): drop Query import aliases for query utils Signed-off-by: William Phetsinorath Change-Id: I9681a6be979533285c8ea54ae6f9b9956a6a6964 Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster-queries.utils.ts | 12 +++++----- .../modules/cluster/cluster-testing.utils.ts | 14 +---------- .../src/modules/cluster/cluster.service.ts | 24 +++++++++---------- .../src/modules/cluster/cluster.utils.ts | 18 +++++++------- packages/shared/src/contracts/cluster.ts | 1 - 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index 4035dae177..e85a076926 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -110,18 +110,18 @@ export function generateClusterWhereInput(userId?: string): Prisma.ClusterWhereI : {} } -export async function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: string) { - return (await prisma.cluster.findUniqueOrThrow({ +export function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: string) { + return prisma.cluster.findUniqueOrThrow({ where: { id }, select: { projects: true }, - }))?.projects + }).then(cluster => cluster.projects) } -export async function listStagesByClusterId(prisma: Prisma.TransactionClient, id: string) { - return (await prisma.cluster.findUniqueOrThrow({ +export function listStagesByClusterId(prisma: Prisma.TransactionClient, id: string) { + return prisma.cluster.findUniqueOrThrow({ where: { id }, select: { stages: true }, - }))?.stages + }).then(cluster => cluster.stages) } export function createCluster( diff --git a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts index 318001729f..0fbd7d9a0a 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -1,4 +1,4 @@ -import type { CleanedCluster, ClusterDetails, CreateClusterBody } from '@cpn-console/shared' +import type { CleanedCluster, CreateClusterBody } from '@cpn-console/shared' import type { Cluster, Kubeconfig, Stage } from '@prisma/client' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' import { faker } from '@faker-js/faker' @@ -106,18 +106,6 @@ export function makeContractCluster(overrides: Partial = {}): Cl } satisfies CleanedCluster } -export function makeContractClusterDetails(overrides: Partial = {}): ClusterDetails { - return { - ...makeContractCluster(), - projectIds: [faker.string.uuid()], - kubeconfig: { - user: { username: faker.internet.username() }, - cluster: { tlsServerName: faker.internet.domainName() }, - }, - ...overrides, - } satisfies ClusterDetails -} - export function makeCreateClusterBody(overrides: Partial = {}): CreateClusterBody { const cluster = makeContractCluster() return { diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 8df5f40d4a..58a5d70961 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -11,23 +11,23 @@ import { BadRequestException, ConflictException, Inject, Injectable, NotFoundExc import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { - createCluster as createClusterQuery, - deleteCluster as deleteClusterQuery, + createCluster, + deleteCluster, getClusterById, getClusterByLabel, - getClusterDetails as getClusterDetailsQuery, + getClusterDetails, getClusterEnvironments, getClusterUsage, getProjectsByClusterId, linkClusterToProjects, linkClusterToStages, linkZoneToClusters, - listClusters as listClustersQuery, + listClusters, generateClusterWhereInput, listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, - updateCluster as updateClusterQuery, + updateCluster, } from './cluster-queries.utils' const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public @@ -46,11 +46,11 @@ export class ClusterService { private async listClusters(userId?: string): Promise { const where = generateClusterWhereInput(userId) - return listClustersQuery(this.prisma, where) + return listClusters(this.prisma, where) } async getClusterDetailsRecord(clusterId: string): Promise { - return getClusterDetailsQuery(this.prisma, clusterId) + return getClusterDetails(this.prisma, clusterId) } async getClusterUsage(clusterId: string): Promise { @@ -72,7 +72,7 @@ export class ClusterService { const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data const clusterCreated = await this.prisma.$transaction(async (tx) => { - const clusterCreated = await createClusterQuery(tx, clusterData, kubeconfig, zoneId) + const clusterCreated = await createCluster(tx, clusterData, kubeconfig, zoneId) if (data.privacy !== CLUSTER_PUBLIC && projectIds?.length) { await linkClusterToProjects(tx, clusterCreated.id, projectIds) @@ -108,7 +108,7 @@ export class ClusterService { const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data await this.prisma.$transaction(async (tx) => { - const clusterUpdated = await updateClusterQuery(tx, clusterId, clusterData, kubeconfig) + const clusterUpdated = await updateCluster(tx, clusterId, clusterData, kubeconfig) if (zoneId) { await linkZoneToClusters(tx, zoneId, [clusterId]) @@ -155,7 +155,7 @@ export class ClusterService { const envs = await this.prisma.environment.deleteMany({ where: { clusterId } }) forcedCount = envs.count } - await deleteClusterQuery(this.prisma, clusterId) + await deleteCluster(this.prisma, clusterId) return forcedCount } } @@ -169,7 +169,7 @@ function projectsToRemoveFrom(clusterPrivacy: typeof CLUSTER_PUBLIC | typeof CLU async function syncClusterProjectLinks( tx: Prisma.TransactionClient, - clusterUpdated: Awaited>, + clusterUpdated: Awaited>, clusterId: string, projectIds: string[] | undefined, ) { @@ -193,7 +193,7 @@ async function syncClusterProjectLinks( async function syncClusterStageLinks( tx: Prisma.TransactionClient, - clusterUpdated: Awaited>, + clusterUpdated: Awaited>, clusterId: string, stageIds: string[] | undefined, ) { diff --git a/apps/server-nestjs/src/modules/cluster/cluster.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster.utils.ts index e7ffb2af3b..583e01eaa9 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.utils.ts @@ -2,17 +2,15 @@ import type { CleanedCluster, ClusterAssociatedEnvironments, ClusterDetails } fr import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' import { KubeconfigSchema } from '@cpn-console/shared' -export function toCluster(record: ClusterListRecord): CleanedCluster { - const { stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster } = record - return { - ...cluster, - infos: infos ?? '', - stageIds: stages.map(({ id }) => id), - } -} - export function toClusters(records: ClusterListRecord[]): CleanedCluster[] { - return records.map(toCluster) + return records.map((record) => { + const { stages, infos, secretName, kubeConfigId, createdAt, updatedAt, ...cluster } = record + return { + ...cluster, + infos: infos ?? '', + stageIds: stages.map(({ id }) => id), + } + }) } export function toClusterDetails(record: ClusterDetailsRecord): ClusterDetails { diff --git a/packages/shared/src/contracts/cluster.ts b/packages/shared/src/contracts/cluster.ts index a17c2cb886..14db5a3790 100644 --- a/packages/shared/src/contracts/cluster.ts +++ b/packages/shared/src/contracts/cluster.ts @@ -135,4 +135,3 @@ export const clusterContract = contractInstance.router({ }) export type ClusterAssociatedEnvironments = ClientInferResponseBody -export type ClusterList = ClientInferResponseBody From 265afeabe190ae623c913037acfa1585d400cdda Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:25:50 +0200 Subject: [PATCH 16/22] refactor(cluster): drop Kubeconfig import alias Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../src/modules/cluster/cluster-queries.utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index e85a076926..dd3d9bfdde 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -1,4 +1,4 @@ -import type { Kubeconfig as KubeconfigBody } from '@cpn-console/shared' +import type { Kubeconfig } from '@cpn-console/shared' import type { Cluster, Prisma } from '@prisma/client' import { ClusterPrivacySchema } from '@cpn-console/shared' @@ -127,7 +127,7 @@ export function listStagesByClusterId(prisma: Prisma.TransactionClient, id: stri export function createCluster( prisma: Prisma.TransactionClient, data: Omit, - kubeconfig: Pick, + kubeconfig: Pick, zoneId: string, ) { return prisma.cluster.create({ @@ -148,7 +148,7 @@ export function updateCluster( prisma: Prisma.TransactionClient, id: string, data: Partial>, - kubeconfig?: Pick, + kubeconfig?: Pick, ) { return prisma.cluster.update({ where: { id }, From 3fda1b4d05307bc2c73749cb510fe5533eb3f16c Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:19:09 +0200 Subject: [PATCH 17/22] refactor(cluster): move syncClusterStageLinks to queries utils Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ic0c77b63ed86e4d744e584c7b4ed2d346a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster-queries.utils.ts | 18 +++++++++++++++ .../src/modules/cluster/cluster.service.ts | 23 ++----------------- .../src/modules/events/app-events.service.ts | 1 + 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index dd3d9bfdde..bb07b7fc27 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -202,6 +202,24 @@ export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: str }) } +export async function syncClusterStageLinks( + tx: Prisma.TransactionClient, + clusterUpdated: Awaited>, + clusterId: string, + stageIds: string[] | undefined, +) { + if (!stageIds) return + + await linkClusterToStages(tx, clusterId, stageIds) + + const dbStages = await listStagesByClusterId(tx, clusterId) + for (const stage of dbStages ?? []) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(tx, clusterUpdated.id, stage.id) + } + } +} + export function deleteCluster(prisma: Prisma.TransactionClient, id: string) { return prisma.cluster.delete({ where: { id } }) } diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 58a5d70961..4e402ee5e6 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -13,6 +13,7 @@ import { PrismaService } from '../infrastructure/database/prisma.service' import { createCluster, deleteCluster, + generateClusterWhereInput, getClusterById, getClusterByLabel, getClusterDetails, @@ -23,10 +24,8 @@ import { linkClusterToStages, linkZoneToClusters, listClusters, - generateClusterWhereInput, - listStagesByClusterId, removeClusterFromProject, - removeClusterFromStage, + syncClusterStageLinks, updateCluster, } from './cluster-queries.utils' @@ -190,21 +189,3 @@ async function syncClusterProjectLinks( await removeClusterFromProject(tx, clusterUpdated.id, projectId) } } - -async function syncClusterStageLinks( - tx: Prisma.TransactionClient, - clusterUpdated: Awaited>, - clusterId: string, - stageIds: string[] | undefined, -) { - if (!stageIds) return - - await linkClusterToStages(tx, clusterId, stageIds) - - const dbStages = await listStagesByClusterId(tx, clusterId) - for (const stage of dbStages ?? []) { - if (!stageIds.includes(stage.id)) { - await removeClusterFromStage(tx, clusterUpdated.id, stage.id) - } - } -} diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index eb5ffb05c4..364b886ef1 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -66,6 +66,7 @@ export type EventLogAction | 'Create Repository' | 'Update Repository' | 'Delete Repository' | 'Sync Repository' | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' | 'Create zone' | 'Update zone' | 'Delete zone' + | 'Create Cluster' | 'Update Cluster' | 'Delete Cluster' export interface EventContext { /** Action label persisted in the admin log. */ From c0ed827e5fa75a3817a78765ba11e06b290f5012 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:43:23 +0200 Subject: [PATCH 18/22] refactor(cluster): restore local plugin-failure throws per zone pattern Signed-off-by: William Phetsinorath Change-Id: I425f9664368d246076e7d5891de423606a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster.service.spec.ts | 12 +++--- .../src/modules/cluster/cluster.service.ts | 23 +++++++++-- .../environment/environment.service.spec.ts | 16 ++++---- .../environment/environment.service.ts | 16 +++++--- .../src/modules/events/app-events.service.ts | 31 ++++----------- .../src/modules/events/app-events.utils.ts | 2 +- .../repository/repository.service.spec.ts | 39 ++++++++++--------- .../modules/repository/repository.service.ts | 27 +++++++++---- 8 files changed, 94 insertions(+), 72 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts index 8a48cc6f3b..5986f5ed33 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -111,14 +111,14 @@ describe('clusterService', () => { expect(result.id).toEqual(details.id) expect(prisma.cluster.create).toHaveBeenCalled() expect(prisma.cluster.update).toHaveBeenCalled() - expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: cluster.id }), expect.any(Object), 'Echec des services à la création/mise à jour du cluster') + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: cluster.id }), expect.any(Object)) }) it('rejects cluster creation when a plugin reports KO', async () => { prisma.cluster.findUnique.mockResolvedValue(null) prisma.cluster.create.mockResolvedValue(makeCluster()) prisma.cluster.findUniqueOrThrow.mockResolvedValue(makeClusterDetailsRecord()) - appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) + appEvents.emitClusterEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) await expect( service.createCluster( @@ -172,7 +172,7 @@ describe('clusterService', () => { expect(result.id).toEqual(record.id) expect(prisma.cluster.update).toHaveBeenCalled() - expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id }), expect.any(Object), 'Echec des services à la création/mise à jour du cluster') + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id }), expect.any(Object)) }) it('rejects updating a missing cluster', async () => { @@ -196,13 +196,13 @@ describe('clusterService', () => { expect(forcedCount).toBe(0) expect(appEvents.emitClusterEvent).toHaveBeenCalledBefore(prisma.cluster.delete) - expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.delete', expect.objectContaining({ clusterId: record.id }), expect.any(Object), 'Echec des services à la suppression du cluster') + expect(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.delete', expect.objectContaining({ clusterId: record.id }), expect.any(Object)) }) it('rejects cluster deletion and keeps the row when a plugin reports KO', async () => { const record = makeClusterListRecord() prisma.environment.findFirst.mockResolvedValue(null) - appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la suppression du cluster')) + appEvents.emitClusterEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) await expect( service.deleteCluster({ @@ -240,7 +240,7 @@ describe('clusterService', () => { argocdUrl: 'https://example.com', }) prisma.cluster.findUniqueOrThrow.mockResolvedValue(makeCluster()) - appEvents.emitClusterEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) + appEvents.emitClusterEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) await expect(service.updateCluster({ infos: 'x' }, record.id, 'u', 'r')) .rejects.toThrow(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 4e402ee5e6..e4bce4a8dc 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -4,12 +4,14 @@ import type { UpdateClusterBody, } from '@cpn-console/shared' import type { Prisma } from '@prisma/client' +import type { ClusterEventName, ClusterEventPayload, EventContext } from '../events/app-events.service' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' import { AdminAuthorized, ClusterPrivacySchema } from '@cpn-console/shared' -import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' import { PrismaService } from '../infrastructure/database/prisma.service' +import { getFailedPlugins } from '../plugin/plugin.utils' import { createCluster, deleteCluster, @@ -84,7 +86,7 @@ export class ClusterService { return clusterCreated }) - await this.appEvents.emitClusterEvent('cluster.upsert', { clusterId: clusterCreated.id, zoneId }, { + await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId: clusterCreated.id, zoneId }, { action: 'Create Cluster', userId, requestId, @@ -117,7 +119,7 @@ export class ClusterService { await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) }) - await this.appEvents.emitClusterEvent('cluster.upsert', { clusterId, zoneId: dbCluster.zoneId }, { + await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId, zoneId: dbCluster.zoneId }, { action: 'Update Cluster', userId, requestId, @@ -143,7 +145,7 @@ export class ClusterService { // and its (forced) environments only disappear once every plugin reported // OK — a KO leaves everything replayable instead of a 204 with a dangling // cluster. - await this.appEvents.emitClusterEvent('cluster.delete', { clusterId }, { + await this.emitClusterEventAndThrowOnFailure('cluster.delete', { clusterId }, { action: 'Delete Cluster', userId, requestId, @@ -157,6 +159,19 @@ export class ClusterService { await deleteCluster(this.prisma, clusterId) return forcedCount } + + private async emitClusterEventAndThrowOnFailure( + event: ClusterEventName, + payload: ClusterEventPayload, + context: EventContext, + failureMessage: string, + ): Promise { + const results = await this.appEvents.emitClusterEvent(event, payload, context) + + if (getFailedPlugins(results).length) { + throw new UnprocessableEntityException(failureMessage) + } + } } function projectsToRemoveFrom(clusterPrivacy: typeof CLUSTER_PUBLIC | typeof CLUSTER_DEDICATED, projectIds: string[] | undefined, dbProjectIds: string[]): string[] { diff --git a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts index ba9b270fbd..91b2c8c4ea 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -43,7 +43,9 @@ describe('environmentService', () => { autosync: true, } satisfies CreateEnvironment - const failedReconciliation = new InternalServerErrorException('Echec des services') + const failedReconciliation = { + gitlab: { status: 'KO', message: 'Unable to provision environment', executionTime: 1, error: new Error('boom') }, + } as const const validUpdateEnvironment = { cpu: 4, @@ -110,7 +112,7 @@ describe('environmentService', () => { action: 'Create Environment', userId, requestId, - }, expect.any(String)) + }) expect(result).toEqual(environment) }) @@ -118,7 +120,7 @@ describe('environmentService', () => { const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) validation.validateCreate.mockResolvedValue(undefined) datastore.createEnvironment.mockResolvedValue(environment) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) .rejects.toThrow(InternalServerErrorException) @@ -152,7 +154,7 @@ describe('environmentService', () => { action: 'Update Environment', userId, requestId, - }, expect.any(String)) + }) expect(result).toEqual(updated) }) @@ -161,7 +163,7 @@ describe('environmentService', () => { datastore.getProjectEnvironment.mockResolvedValue(existing) validation.validateUpdate.mockResolvedValue(undefined) datastore.updateEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment })) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) .rejects.toThrow(InternalServerErrorException) @@ -202,13 +204,13 @@ describe('environmentService', () => { action: 'Delete Environment', userId, requestId, - }, expect.any(String)) + }) }) it('should wait for the reconciliation and reject when a service fails', async () => { datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) .rejects.toThrow(InternalServerErrorException) diff --git a/apps/server-nestjs/src/modules/environment/environment.service.ts b/apps/server-nestjs/src/modules/environment/environment.service.ts index 947c8f96c7..85de7b3ffe 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -5,9 +5,11 @@ import type { EnvironmentWithCluster, EnvironmentWithDeploymentsCount } from './ import { Inject, Injectable, + InternalServerErrorException, NotFoundException, } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' +import { getFailedPlugins } from '../plugin/plugin.utils' import { EnvironmentDatastoreService } from './environment-datastore.service' import { EnvironmentValidationService } from './environment-validation.service' @@ -41,7 +43,7 @@ export class EnvironmentService { autosync: environmentToCreate.autosync, }) - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Create Environment', userId, @@ -62,7 +64,7 @@ export class EnvironmentService { autosync: environmentToUpdate.autosync, }) - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Update Environment', userId, @@ -75,7 +77,7 @@ export class EnvironmentService { async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { await this.getProjectEnvironmentOrThrow(projectId, environmentId) await this.environmentDatastoreService.deleteEnvironment(environmentId) - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Delete Environment', userId, @@ -99,13 +101,17 @@ export class EnvironmentService { * with a success on a project that AppEventsService just marked `failed`. The row * change stays committed — the reconciliation is replayable. */ - private async reconcileProject( + private async reconcileProjectAndThrowOnFailure( projectId: string, action: EventLogAction, userId: string, requestId: string, failureMessage: string, ): Promise { - await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }, failureMessage) + const results = await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + + if (getFailedPlugins(results).length) { + throw new InternalServerErrorException(failureMessage) + } } } diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 364b886ef1..628bf32966 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -1,7 +1,7 @@ import type { ConfigType } from '@nestjs/config' import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' -import { Inject, Injectable, Logger, UnprocessableEntityException } from '@nestjs/common' +import { Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' @@ -103,7 +103,6 @@ export class AppEventsService { event: ProjectEventName, projectOrId: string | ProjectWithDetails, context: EventContext, - failureMessage?: string, ): Promise { const project = typeof projectOrId === 'string' ? await getProject(this.prisma, projectOrId) @@ -116,7 +115,6 @@ export class AppEventsService { const results = await this.emitAndLog(event, project, project.id, context) await this.updateProjectStatus(event, project.id, results) - this.throwOnPluginFailure(event, results, failureMessage) return results } @@ -124,9 +122,8 @@ export class AppEventsService { event: ProjectMemberEventName, payload: ProjectMemberEventPayload, context: EventContext, - failureMessage?: string, ): Promise { - return this.emitAndLog(event, payload, payload.projectId, context, failureMessage) + return this.emitAndLog(event, payload, payload.projectId, context) } /** @@ -138,23 +135,22 @@ export class AppEventsService { event: RepositoryEventName, payload: RepositorySyncEventPayload, context: EventContext, - failureMessage?: string, ): Promise { - return this.emitAndLog(event, payload, payload.projectId, context, failureMessage) + return this.emitAndLog(event, payload, payload.projectId, context) } /** - * Emits a cluster event. Parity with the legacy server: pass a failure message so a listener - * KO surfaces as a 422, and only emit the delete after every plugin cleaned up - * successfully (the caller must not have removed the row yet). + * Emits a cluster event. The caller awaits the merged results and answers 422 on + * failure, mirroring emitZoneEvent consumers (legacy hooks 422 on KO), and only + * emits the delete after every plugin cleaned up successfully (the caller must + * not have removed the row yet). */ async emitClusterEvent( event: ClusterEventName, payload: ClusterEventPayload, context: EventContext, - failureMessage?: string, ): Promise { - return this.emitAndLog(event, payload, null, context, failureMessage) + return this.emitAndLog(event, payload, null, context) } // Emits a zone event. Zones have no project row: the log carries no project id @@ -173,7 +169,6 @@ export class AppEventsService { payload: unknown, projectId: string | null, context: EventContext, - failureMessage?: string, ): Promise { const start = process.hrtime.bigint() const responses = await this.eventEmitter.emitAsync(event, payload) @@ -190,19 +185,9 @@ export class AppEventsService { projectId, }) - this.throwOnPluginFailure(event, results, failureMessage) return results } - /** A listener KO surfaces as a 422 on the caller's request. */ - private throwOnPluginFailure(event: string, results: PluginResults, failureMessage?: string): void { - const failed = getFailedPlugins(results) - if (failureMessage && failed.length) { - this.logger.error(`${event} failed (failed=${failed.join(',')})`) - throw new UnprocessableEntityException(failureMessage) - } - } - /** * Reflects the listeners' outcome on the project row: * any KO result marks the project `failed`; a fully successful upsert marks it diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index c3e3f9b95d..a9cf62706b 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -3,7 +3,7 @@ import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.u import { getErrorHttpDetails } from '../../utils/http.utils' import { getFailedPlugins } from '../plugin/plugin.utils' -/** Per-service result as persisted in the admin logs (parsed by LogSchema). */ +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ export interface LoggablePluginResult { status: { result: 'OK' | 'KO' diff --git a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts index 5155d1c0a1..ac1db17251 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.spec.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.spec.ts @@ -68,7 +68,9 @@ describe('repositoryService', () => { service = module.get(RepositoryService) }) - const failedReconciliation = new UnprocessableEntityException('Echec des services') + const failedReconciliation = { + gitlab: { status: 'KO', message: 'Unable to provision repository', executionTime: 1, error: new Error('boom') }, + } as const it('should be defined', () => { expect(service).toBeDefined() @@ -117,7 +119,7 @@ describe('repositoryService', () => { action: 'Create Repository', userId, requestId, - }, expect.any(String)) + }) expect(result).toEqual(repository) }) @@ -134,7 +136,6 @@ describe('repositoryService', () => { 'repository.sync', expect.objectContaining({ internalRepoName: repository.internalRepoName, syncAllBranches: true }), expect.objectContaining({ action: 'Sync Repository' }), - expect.any(String), ) }) @@ -168,7 +169,7 @@ describe('repositoryService', () => { await service.createRepository(projectId, projectSlug, publicRepository, userId, requestId) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' })) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { @@ -176,7 +177,7 @@ describe('repositoryService', () => { datastore.hasRepositoryWithName.mockResolvedValue(false) datastore.createRepository.mockResolvedValue(repository) vault.writeGitlabMirrorCreds.mockResolvedValue(undefined) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.createRepository(projectId, projectSlug, validCreateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -193,7 +194,9 @@ describe('repositoryService', () => { datastore.createRepository.mockResolvedValue(repository) vault.writeGitlabMirrorCreds.mockResolvedValue(undefined) appEvents.emitProjectEvent.mockResolvedValue({}) - appEvents.emitRepositoryEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la synchronisation du dépôt')) + appEvents.emitRepositoryEvent.mockResolvedValue({ + gitlab: { status: 'KO', message: 'Unable to find mirror repository', executionTime: 1, error: new Error('boom') }, + }) await expect(service.createRepository(projectId, projectSlug, validCreateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -243,7 +246,7 @@ describe('repositoryService', () => { action: 'Update Repository', userId, requestId, - }, expect.any(String)) + }) expect(result).toEqual(updated) }) @@ -258,7 +261,7 @@ describe('repositoryService', () => { expect(vault.deleteGitlabMirrorCreds).toHaveBeenCalledWith(projectSlug, updated.internalRepoName) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) }) it('leaves Vault untouched when the update carries no credential change', async () => { @@ -271,14 +274,14 @@ describe('repositoryService', () => { expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() expect(vault.deleteGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { const repository = makeRepository({ id: repositoryId, projectId }) datastore.getRepositoryById.mockResolvedValue(repository) datastore.updateRepository.mockResolvedValue(repository) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.updateRepository(projectId, projectSlug, repositoryId, validUpdateRepository, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -317,7 +320,6 @@ describe('repositoryService', () => { syncAllBranches: true, }, { action: 'Sync Repository', userId, requestId }, - expect.any(String), ) expect(datastore.updateBranchName).not.toHaveBeenCalled() }) @@ -344,7 +346,6 @@ describe('repositoryService', () => { 'repository.sync', expect.objectContaining({ syncAllBranches: false, branchName }), expect.objectContaining({ action: 'Sync Repository' }), - expect.any(String), ) }) @@ -353,7 +354,9 @@ describe('repositoryService', () => { const repository = makeRepository({ id: repositoryId, projectId }) datastore.getRepositoryById.mockResolvedValue(repository) datastore.updateBranchName.mockResolvedValue(repository) - appEvents.emitRepositoryEvent.mockRejectedValue(new UnprocessableEntityException('Echec des services à la synchronisation du dépôt')) + appEvents.emitRepositoryEvent.mockResolvedValue({ + gitlab: { status: 'KO', message: 'Unable to find mirror repository', executionTime: 1, error: new Error('boom') }, + }) await expect(service.syncRepository(projectId, projectSlug, repositoryId, { syncAllBranches: false, branchName }, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -383,13 +386,13 @@ describe('repositoryService', () => { action: 'Delete Repository', userId, requestId, - }, expect.any(String)) + }) }) it('waits for the reconciliation and rejects with 422 when a plugin fails', async () => { datastore.getRepositoryById.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) datastore.deleteRepository.mockResolvedValue(makeRepository({ id: repositoryId, projectId })) - appEvents.emitProjectEvent.mockRejectedValue(failedReconciliation) + appEvents.emitProjectEvent.mockResolvedValue(failedReconciliation) await expect(service.deleteRepository(projectId, repositoryId, userId, requestId)) .rejects.toThrow(UnprocessableEntityException) @@ -431,7 +434,7 @@ describe('repositoryService', () => { expect(result).toEqual(repository) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Create Repository' })) }) it('updates a repository without applying the credential intent', async () => { @@ -445,7 +448,7 @@ describe('repositoryService', () => { expect(result).toEqual(updated) expect(vault.writeGitlabMirrorCreds).not.toHaveBeenCalled() expect(vault.deleteGitlabMirrorCreds).not.toHaveBeenCalled() - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Update Repository' })) }) it('deletes a repository', async () => { @@ -456,7 +459,7 @@ describe('repositoryService', () => { await vaultlessService.deleteRepository(projectId, repositoryId, userId, requestId) expect(datastore.deleteRepository).toHaveBeenCalledWith(repositoryId) - expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Delete Repository' }), expect.any(String)) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, expect.objectContaining({ action: 'Delete Repository' })) }) }) }) diff --git a/apps/server-nestjs/src/modules/repository/repository.service.ts b/apps/server-nestjs/src/modules/repository/repository.service.ts index 08bce2605f..c1f5bcbd06 100644 --- a/apps/server-nestjs/src/modules/repository/repository.service.ts +++ b/apps/server-nestjs/src/modules/repository/repository.service.ts @@ -3,8 +3,9 @@ import type { Repository } from '@prisma/client' import type { EventLogAction, RepositorySyncEventPayload } from '../events/app-events.service' import type { PluginResults } from '../plugin/plugin.utils' import type { RepositoryMirrorCredentialUpdate } from './repository.utils' -import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException, Optional, UnprocessableEntityException } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' +import { getFailedPlugins } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { RepositoryDatastoreService } from './repository-datastore.service' import { buildRepositoryCreateData, buildRepositoryUpdateData, parseRepositoryCredentialUpdate } from './repository.utils' @@ -48,7 +49,7 @@ export class RepositoryService { } } - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Create Repository', userId, @@ -68,6 +69,8 @@ export class RepositoryService { ) if (!Object.keys(results).length) { this.logger.warn(`repository.sync after creation had no listener (repositoryId=${repository.id}): no sync plugin is enabled`) + } else if (getFailedPlugins(results).length) { + throw new UnprocessableEntityException('Echec des services à la synchronisation du dépôt') } } @@ -86,7 +89,7 @@ export class RepositoryService { await this.repositoryDatastoreService.updateBranchName(repositoryId, syncRequest.branchName) } - await this.syncRepositoryMirror( + const results = await this.syncRepositoryMirror( { projectId, projectSlug, @@ -98,6 +101,10 @@ export class RepositoryService { userId, requestId, ) + + if (getFailedPlugins(results).length) { + throw new UnprocessableEntityException('Echec des services à la synchronisation du dépôt') + } } private syncRepositoryMirror(payload: RepositorySyncEventPayload, userId: string, requestId: string): Promise { @@ -105,7 +112,7 @@ export class RepositoryService { action: 'Sync Repository', userId, requestId, - }, 'Echec des services à la synchronisation du dépôt') + }) } async updateRepository(projectId: string, projectSlug: string, repositoryId: string, repositoryToUpdate: UpdateRepository, userId: string, requestId: string): Promise { @@ -122,7 +129,7 @@ export class RepositoryService { // removal) must be persisted first. await this.applyMirrorCredentialUpdate(projectSlug, repository, parseRepositoryCredentialUpdate(repositoryToUpdate)) - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Update Repository', userId, @@ -164,7 +171,7 @@ export class RepositoryService { async deleteRepository(projectId: string, repositoryId: string, userId: string, requestId: string): Promise { await this.getProjectRepositoryOrThrow(projectId, repositoryId) await this.repositoryDatastoreService.deleteRepository(repositoryId) - await this.reconcileProject( + await this.reconcileProjectAndThrowOnFailure( projectId, 'Delete Repository', userId, @@ -189,13 +196,17 @@ export class RepositoryService { * success on a project that AppEventsService just marked `failed`. The row change * stays committed — the reconciliation is replayable. */ - private async reconcileProject( + private async reconcileProjectAndThrowOnFailure( projectId: string, action: EventLogAction, userId: string, requestId: string, failureMessage: string, ): Promise { - await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }, failureMessage) + const results = await this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + + if (getFailedPlugins(results).length) { + throw new UnprocessableEntityException(failureMessage) + } } } From 0b4cb4d18bf0d3c5c37ba5f3fc2c8984ac9ded6d Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:57:54 +0200 Subject: [PATCH 19/22] fix(server-nestjs): restore legacy argocd cluster config parity - generateClusterTlsClientConfig now nests tlsClientConfig under username, password and bearerToken, matching the legacy convertConfig shape; secrets stay out of the tlsClientConfig block - split create/update failure messages to their legacy-exact wording - stop mutating the typed update body before destructuring it Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I3327b416d19ce00fa7be415daf162af16a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../src/modules/argocd/argocd.utils.ts | 14 ++++++++------ .../src/modules/cluster/cluster.service.spec.ts | 2 +- .../src/modules/cluster/cluster.service.ts | 9 ++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts index d6fc2ec339..687a5150fe 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -3,14 +3,16 @@ import { stringify } from 'yaml' export function generateClusterTlsClientConfig(kubeconfig: Kubeconfig) { return { - ...kubeconfig.user.keyData && { keyData: kubeconfig.user.keyData }, - ...kubeconfig.user.certData && { certData: kubeconfig.user.certData }, - ...kubeconfig.cluster.caData && !kubeconfig.cluster.skipTLSVerify && { caData: kubeconfig.cluster.caData }, - ...kubeconfig.cluster.skipTLSVerify && { insecure: kubeconfig.cluster.skipTLSVerify }, - serverName: kubeconfig.cluster.tlsServerName, ...kubeconfig.user.username && { username: kubeconfig.user.username }, ...kubeconfig.user.password && { password: kubeconfig.user.password }, ...kubeconfig.user.token && { bearerToken: kubeconfig.user.token }, + tlsClientConfig: { + ...kubeconfig.user.keyData && { keyData: kubeconfig.user.keyData }, + ...kubeconfig.user.certData && { certData: kubeconfig.user.certData }, + ...kubeconfig.cluster.caData && !kubeconfig.cluster.skipTLSVerify && { caData: kubeconfig.cluster.caData }, + ...kubeconfig.cluster.skipTLSVerify && { insecure: kubeconfig.cluster.skipTLSVerify }, + serverName: kubeconfig.cluster.tlsServerName, + }, } } @@ -28,6 +30,6 @@ export function generateClusterSecretData(cluster: { label: string, clusterResou name: cluster.label, clusterResources: String(cluster.clusterResources), server: kubeconfig.cluster.server, - config: stringify({ tlsClientConfig: generateClusterTlsClientConfig(kubeconfig) }), + config: stringify(generateClusterTlsClientConfig(kubeconfig)), } } diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts index 5986f5ed33..d5d29557a0 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -243,6 +243,6 @@ describe('clusterService', () => { appEvents.emitClusterEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) await expect(service.updateCluster({ infos: 'x' }, record.id, 'u', 'r')) - .rejects.toThrow(new UnprocessableEntityException('Echec des services à la création/mise à jour du cluster')) + .rejects.toThrow(new UnprocessableEntityException('Echec des services à la mise à jour du cluster')) }) }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index e4bce4a8dc..f9387e8a98 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -90,7 +90,7 @@ export class ClusterService { action: 'Create Cluster', userId, requestId, - }, 'Echec des services à la création/mise à jour du cluster') + }, 'Echec des services à la création du cluster') return this.getClusterDetailsRecord(clusterCreated.id) } @@ -101,12 +101,11 @@ export class ClusterService { userId: string, requestId: string, ): Promise { - if (data?.privacy === CLUSTER_PUBLIC) delete data.projectIds - const dbCluster = await getClusterById(this.prisma, clusterId) if (!dbCluster) throw new NotFoundException('Cluster not found') const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data + const publicProjectIds = data.privacy === CLUSTER_PUBLIC ? undefined : projectIds await this.prisma.$transaction(async (tx) => { const clusterUpdated = await updateCluster(tx, clusterId, clusterData, kubeconfig) @@ -115,7 +114,7 @@ export class ClusterService { await linkZoneToClusters(tx, zoneId, [clusterId]) } - await syncClusterProjectLinks(tx, clusterUpdated, clusterId, projectIds) + await syncClusterProjectLinks(tx, clusterUpdated, clusterId, publicProjectIds) await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) }) @@ -123,7 +122,7 @@ export class ClusterService { action: 'Update Cluster', userId, requestId, - }, 'Echec des services à la création/mise à jour du cluster') + }, 'Echec des services à la mise à jour du cluster') return this.getClusterDetailsRecord(clusterId) } From bdce543f064bf9939b3a6269d901ce9ffb76c9d3 Mon Sep 17 00:00:00 2001 From: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:11:12 +0200 Subject: [PATCH 20/22] refactor(server-nestjs): move cluster project-link sync to cluster-queries.utils Colocate syncClusterProjectLinks beside its exported stage twin syncClusterStageLinks; fold the single-caller projectsToRemoveFrom helper into a plain filter at its only call site. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I0ef7ec192f02eac54de38aafd129d33a6a6a6964 Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> --- .../modules/cluster/cluster-queries.utils.ts | 26 ++++++++++++++ .../src/modules/cluster/cluster.service.ts | 36 +------------------ 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index bb07b7fc27..f51c17af74 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -3,6 +3,7 @@ import type { Cluster, Prisma } from '@prisma/client' import { ClusterPrivacySchema } from '@cpn-console/shared' const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public +const CLUSTER_DEDICATED = ClusterPrivacySchema.enum.dedicated export const clusterListSelect = { id: true, @@ -220,6 +221,31 @@ export async function syncClusterStageLinks( } } +export async function syncClusterProjectLinks( + tx: Prisma.TransactionClient, + clusterUpdated: Awaited>, + clusterId: string, + projectIds: string[] | undefined, +) { + if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { + await linkClusterToProjects(tx, clusterId, projectIds) + } + + if (clusterUpdated.privacy === CLUSTER_PUBLIC) { + const dbProjects = await getProjectsByClusterId(tx, clusterId) + for (const projectId of dbProjects?.map(project => project.id) ?? []) { + await removeClusterFromProject(tx, clusterUpdated.id, projectId) + } + return + } + + const dbProjects = await getProjectsByClusterId(tx, clusterId) + const dbProjectIds = dbProjects?.map(project => project.id) ?? [] + for (const projectId of dbProjectIds.filter(dbProjectId => !projectIds?.includes(dbProjectId))) { + await removeClusterFromProject(tx, clusterUpdated.id, projectId) + } +} + export function deleteCluster(prisma: Prisma.TransactionClient, id: string) { return prisma.cluster.delete({ where: { id } }) } diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index f9387e8a98..0d9f594208 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -3,7 +3,6 @@ import type { CreateClusterBody, UpdateClusterBody, } from '@cpn-console/shared' -import type { Prisma } from '@prisma/client' import type { ClusterEventName, ClusterEventPayload, EventContext } from '../events/app-events.service' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' @@ -21,18 +20,16 @@ import { getClusterDetails, getClusterEnvironments, getClusterUsage, - getProjectsByClusterId, linkClusterToProjects, linkClusterToStages, linkZoneToClusters, listClusters, - removeClusterFromProject, + syncClusterProjectLinks, syncClusterStageLinks, updateCluster, } from './cluster-queries.utils' const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public -const CLUSTER_DEDICATED = ClusterPrivacySchema.enum.dedicated @Injectable() export class ClusterService { @@ -172,34 +169,3 @@ export class ClusterService { } } } - -function projectsToRemoveFrom(clusterPrivacy: typeof CLUSTER_PUBLIC | typeof CLUSTER_DEDICATED, projectIds: string[] | undefined, dbProjectIds: string[]): string[] { - const keepDedicated = clusterPrivacy === CLUSTER_DEDICATED && projectIds - ? projectIds - : [] - return dbProjectIds.filter(dbProjectId => !keepDedicated.includes(dbProjectId)) -} - -async function syncClusterProjectLinks( - tx: Prisma.TransactionClient, - clusterUpdated: Awaited>, - clusterId: string, - projectIds: string[] | undefined, -) { - if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { - await linkClusterToProjects(tx, clusterId, projectIds) - } - - if (clusterUpdated.privacy === CLUSTER_PUBLIC) { - const dbProjects = await getProjectsByClusterId(tx, clusterId) - for (const projectId of dbProjects?.map(project => project.id) ?? []) { - await removeClusterFromProject(tx, clusterUpdated.id, projectId) - } - return - } - - const dbProjects = await getProjectsByClusterId(tx, clusterId) - for (const projectId of projectsToRemoveFrom(clusterUpdated.privacy, projectIds, dbProjects?.map(project => project.id) ?? [])) { - await removeClusterFromProject(tx, clusterUpdated.id, projectId) - } -} From fc9f44f5c7b59fa3760168604c5120c8d76b940c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 8 Oct 2026 15:55:20 +0200 Subject: [PATCH 21/22] refactor(cluster): align cluster query names with authoring vocabulary Rename generateClusterWhereInput to generateClusterWhere, move getClusterEnvironments to listClusterEnvironments (findMany takes the list verb) and listStagesByClusterId to getStagesByClusterId (single cluster payload), and rename the queries-utils first parameter from prisma to tx, matching the majority convention of the tree. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I84a4e9d33bcf75e65fd96e2c7faebe8b6a6a6964 --- .../modules/cluster/cluster-queries.utils.ts | 68 +++++++++---------- .../src/modules/cluster/cluster.service.ts | 8 +-- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts index f51c17af74..d029750aed 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -66,39 +66,39 @@ export const clusterEnvironmentsSelect = { } satisfies Prisma.EnvironmentSelect export type ClusterEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof clusterEnvironmentsSelect }> -export function getClusterById(prisma: Prisma.TransactionClient, id: string) { - return prisma.cluster.findUnique({ +export function getClusterById(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUnique({ where: { id }, include: { kubeconfig: true }, }) } -export function getClusterEnvironments(prisma: Prisma.TransactionClient, clusterId: string) { - return prisma.environment.findMany({ +export function listClusterEnvironments(tx: Prisma.TransactionClient, clusterId: string) { + return tx.environment.findMany({ where: { clusterId }, select: clusterEnvironmentsSelect, }) } -export function getClusterDetails(prisma: Prisma.TransactionClient, id: string) { - return prisma.cluster.findUniqueOrThrow({ +export function getClusterDetails(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ where: { id }, select: clusterDetailsSelect, }) } -export function getClusterByLabel(prisma: Prisma.TransactionClient, label: string) { - return prisma.cluster.findUnique({ where: { label } }) +export function getClusterByLabel(tx: Prisma.TransactionClient, label: string) { + return tx.cluster.findUnique({ where: { label } }) } -export function listClusters(prisma: Prisma.TransactionClient, where: Prisma.ClusterWhereInput) { - return prisma.cluster.findMany({ +export function listClusters(tx: Prisma.TransactionClient, where: Prisma.ClusterWhereInput) { + return tx.cluster.findMany({ where, select: clusterListSelect, }) } -export function generateClusterWhereInput(userId?: string): Prisma.ClusterWhereInput { +export function generateClusterWhere(userId?: string): Prisma.ClusterWhereInput { return userId ? { OR: [ @@ -111,27 +111,27 @@ export function generateClusterWhereInput(userId?: string): Prisma.ClusterWhereI : {} } -export function getProjectsByClusterId(prisma: Prisma.TransactionClient, id: string) { - return prisma.cluster.findUniqueOrThrow({ +export function getProjectsByClusterId(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ where: { id }, select: { projects: true }, }).then(cluster => cluster.projects) } -export function listStagesByClusterId(prisma: Prisma.TransactionClient, id: string) { - return prisma.cluster.findUniqueOrThrow({ +export function getStagesByClusterId(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ where: { id }, select: { stages: true }, }).then(cluster => cluster.stages) } export function createCluster( - prisma: Prisma.TransactionClient, + tx: Prisma.TransactionClient, data: Omit, kubeconfig: Pick, zoneId: string, ) { - return prisma.cluster.create({ + return tx.cluster.create({ data: { ...data, kubeconfig: { @@ -146,12 +146,12 @@ export function createCluster( } export function updateCluster( - prisma: Prisma.TransactionClient, + tx: Prisma.TransactionClient, id: string, data: Partial>, kubeconfig?: Pick, ) { - return prisma.cluster.update({ + return tx.cluster.update({ where: { id }, data: kubeconfig ? { @@ -167,8 +167,8 @@ export function updateCluster( }) } -export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: string, projectIds: string[]) { - return prisma.cluster.update({ +export function linkClusterToProjects(tx: Prisma.TransactionClient, id: string, projectIds: string[]) { + return tx.cluster.update({ where: { id }, data: { projects: { connect: projectIds.map(projectId => ({ id: projectId })) }, @@ -176,8 +176,8 @@ export function linkClusterToProjects(prisma: Prisma.TransactionClient, id: stri }) } -export function linkClusterToStages(prisma: Prisma.TransactionClient, id: string, stageIds: string[]) { - return prisma.cluster.update({ +export function linkClusterToStages(tx: Prisma.TransactionClient, id: string, stageIds: string[]) { + return tx.cluster.update({ where: { id }, data: { stages: { connect: stageIds.map(stageId => ({ id: stageId })) }, @@ -185,8 +185,8 @@ export function linkClusterToStages(prisma: Prisma.TransactionClient, id: string }) } -export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: string, projectId: string) { - return prisma.cluster.update({ +export function removeClusterFromProject(tx: Prisma.TransactionClient, id: string, projectId: string) { + return tx.cluster.update({ where: { id }, data: { projects: { disconnect: { id: projectId } }, @@ -194,8 +194,8 @@ export function removeClusterFromProject(prisma: Prisma.TransactionClient, id: s }) } -export function removeClusterFromStage(prisma: Prisma.TransactionClient, id: string, stageId: string) { - return prisma.cluster.update({ +export function removeClusterFromStage(tx: Prisma.TransactionClient, id: string, stageId: string) { + return tx.cluster.update({ where: { id }, data: { stages: { disconnect: { id: stageId } }, @@ -213,7 +213,7 @@ export async function syncClusterStageLinks( await linkClusterToStages(tx, clusterId, stageIds) - const dbStages = await listStagesByClusterId(tx, clusterId) + const dbStages = await getStagesByClusterId(tx, clusterId) for (const stage of dbStages ?? []) { if (!stageIds.includes(stage.id)) { await removeClusterFromStage(tx, clusterUpdated.id, stage.id) @@ -246,12 +246,12 @@ export async function syncClusterProjectLinks( } } -export function deleteCluster(prisma: Prisma.TransactionClient, id: string) { - return prisma.cluster.delete({ where: { id } }) +export function deleteCluster(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.delete({ where: { id } }) } -export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: string, clusterIds: string[]) { - return prisma.zone.update({ +export function linkZoneToClusters(tx: Prisma.TransactionClient, zoneId: string, clusterIds: string[]) { + return tx.zone.update({ where: { id: zoneId }, data: { clusters: { connect: clusterIds.map(clusterId => ({ id: clusterId })) }, @@ -259,8 +259,8 @@ export function linkZoneToClusters(prisma: Prisma.TransactionClient, zoneId: str }) } -export async function getClusterUsage(prisma: Prisma.TransactionClient, clusterId: string) { - const clusterUsage = await prisma.environment.aggregate({ +export async function getClusterUsage(tx: Prisma.TransactionClient, clusterId: string) { + const clusterUsage = await tx.environment.aggregate({ _sum: { memory: true, cpu: true, gpu: true }, where: { clusterId }, }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 0d9f594208..40eb46b8ce 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -14,15 +14,15 @@ import { getFailedPlugins } from '../plugin/plugin.utils' import { createCluster, deleteCluster, - generateClusterWhereInput, + generateClusterWhere, getClusterById, getClusterByLabel, getClusterDetails, - getClusterEnvironments, getClusterUsage, linkClusterToProjects, linkClusterToStages, linkZoneToClusters, + listClusterEnvironments, listClusters, syncClusterProjectLinks, syncClusterStageLinks, @@ -43,7 +43,7 @@ export class ClusterService { } private async listClusters(userId?: string): Promise { - const where = generateClusterWhereInput(userId) + const where = generateClusterWhere(userId) return listClusters(this.prisma, where) } @@ -56,7 +56,7 @@ export class ClusterService { } async getClusterAssociatedEnvironments(clusterId: string): Promise { - return getClusterEnvironments(this.prisma, clusterId) + return listClusterEnvironments(this.prisma, clusterId) } async createCluster( From 60e743cbfe1da1d1c8dc393701d2e75184f111fb Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 8 Oct 2026 15:55:20 +0200 Subject: [PATCH 22/22] fix(cluster): validate cluster route ids and dedupe makeCluster factory Add ParseUUIDPipe to the four :clusterId routes so an invalid id is rejected with 400 before reaching Prisma, and drop the local makeCluster in favor of the environment module's factory. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I49872a8fe7f2b17d4943f6ad19755d646a6a6964 --- .../modules/cluster/cluster-testing.utils.ts | 22 +++---------------- .../src/modules/cluster/cluster.controller.ts | 10 ++++----- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts index 0fbd7d9a0a..709ee5fd24 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -1,28 +1,12 @@ import type { CleanedCluster, CreateClusterBody } from '@cpn-console/shared' -import type { Cluster, Kubeconfig, Stage } from '@prisma/client' +import type { Kubeconfig, Stage } from '@prisma/client' import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' import { faker } from '@faker-js/faker' +import { makeCluster } from '../environment/environment-testing.utils' import { makeProjectMembers } from '../project-members/project-members-testing.utils' import { makeUser } from '../project/project-testing.utils' -export function makeCluster(overrides: Partial = {}): Cluster { - return { - id: faker.string.uuid(), - label: faker.helpers.slugify(faker.word.sample(5)).toLowerCase(), - privacy: faker.helpers.arrayElement(['public', 'dedicated'] as const), - secretName: faker.string.uuid(), - clusterResources: faker.datatype.boolean(), - kubeConfigId: faker.string.uuid(), - infos: faker.lorem.sentence(), - cpu: faker.number.int({ min: 0, max: 64 }), - gpu: faker.number.int({ min: 0, max: 8 }), - memory: faker.number.int({ min: 0, max: 512 }), - zoneId: faker.string.uuid(), - createdAt: faker.date.past(), - updatedAt: faker.date.past(), - ...overrides, - } satisfies Cluster -} +export { makeCluster } export function makeStage(overrides: Partial = {}): Stage { return { diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index c74b7b7020..b403044bbe 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -7,7 +7,7 @@ import { DeleteClusterQuerySchema, UpdateClusterBodySchema, } from '@cpn-console/shared' -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Query, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' import { UserGuard } from '../infrastructure/permission/user/user.guard' @@ -27,19 +27,19 @@ export class ClusterController { @Get(':clusterId') @RequireAdminPermission('ListClusters') - async getDetails(@Param('clusterId') clusterId: string): Promise { + async getDetails(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { return toClusterDetails(await this.clusterService.getClusterDetailsRecord(clusterId)) } @Get('usage/:clusterId') @RequireAdminPermission('ListClusters') - getUsage(@Param('clusterId') clusterId: string): Promise { + getUsage(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { return this.clusterService.getClusterUsage(clusterId) } @Get(':clusterId/environments') @RequireAdminPermission('ListClusters') - async getEnvironments(@Param('clusterId') clusterId: string): Promise { + async getEnvironments(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { return toClusterAssociatedEnvironments(await this.clusterService.getClusterAssociatedEnvironments(clusterId)) } @@ -71,7 +71,7 @@ export class ClusterController { @Delete(':clusterId') @RequireAdminPermission('ManageClusters') async delete( - @Param('clusterId') clusterId: string, + @Param('clusterId', ParseUUIDPipe) clusterId: string, @Query(new ZodValidationPipe(DeleteClusterQuerySchema)) { force }: DeleteClusterQuery, @AuthUser() user: UserContext, @Req() request: FastifyRequest,