From 0bdbb5a0bdb5abc55c8d9892a4168abb8cfbe431 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:20 +0200 Subject: [PATCH 01/29] 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> Signed-off-by: William Phetsinorath Change-Id: I078ab8b313d60cccd67993443c14cf3c6a6a6964 --- 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 d46e8e6146..344da9e759 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' @@ -41,6 +42,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 70ec0ce5a407da988e442483eb2207e72adcab06 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:20 +0200 Subject: [PATCH 02/29] 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 0261a15cd3f2662d4f250657f2a605d279466da6 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:20 +0200 Subject: [PATCH 03/29] 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> Signed-off-by: William Phetsinorath Change-Id: I7f5dd540e89d54187ba0881d1af533ad6a6a6964 --- 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 c57102b4ab452d904d366f8a2d2fb6d0df780329 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:20 +0200 Subject: [PATCH 04/29] 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> Signed-off-by: William Phetsinorath Change-Id: Ic223ae4950476c208ab605fb31899b236a6a6964 --- 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 344da9e759..dc85db920d 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' @@ -40,7 +39,6 @@ import { getDotenvPaths } from './utils/dotenv.utils' }), TerminusModule.forRoot(), AdminRoleModule, - AdminTokenModule, AuthModule, ClusterModule, DeploymentModule, From 8c56224ef503d7a4df3507a528f7ff217375a8f4 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:21 +0200 Subject: [PATCH 05/29] 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> Signed-off-by: William Phetsinorath Change-Id: I35a88616852aa448b001cd99d0f23bbf6a6a6964 --- .../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 c262f5bfdee60a3d11a8cc10c4195d3d18397f86 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:21 +0200 Subject: [PATCH 06/29] 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> Signed-off-by: William Phetsinorath Change-Id: Ieca62cb2edf5d3366570fb60b0e24ed86a6a6964 --- .../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 6377b1672b5ea941ba9f199e4ab0ed392b6ed70a Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:21 +0200 Subject: [PATCH 07/29] 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> Signed-off-by: William Phetsinorath Change-Id: I05cc45bf006e1e7eaaae7d595a11a02b6a6a6964 --- 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 7bfc3c82d993458f320f702b62676170b9fc8daf Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:21 +0200 Subject: [PATCH 08/29] 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> Signed-off-by: William Phetsinorath Change-Id: Ie806f5daeff91530e9e916d23dbe070b6a6a6964 --- 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 a9cd258b7dae1bca3c638ffa38c347c8e4cf364f Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:22 +0200 Subject: [PATCH 09/29] 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> Signed-off-by: William Phetsinorath Change-Id: I75ea6fd82315a9c9c67f63ad414957936a6a6964 --- .../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 a5207333a9733c269aea779ccd8c9c9102093fd2 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:22 +0200 Subject: [PATCH 10/29] 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> Signed-off-by: William Phetsinorath Change-Id: If466a357f2692be40e3de4170cd37a8d6a6a6964 --- .../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 7f071e186c92075eefbade6f8850b26eda33873a Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:22 +0200 Subject: [PATCH 11/29] 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> Signed-off-by: William Phetsinorath Change-Id: I610c3537602b9b15b472e5fd69a408696a6a6964 --- .../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 f76bcb49a1bc8c1cc988466a0d2dc3b2a20cd5bb Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:23 +0200 Subject: [PATCH 12/29] 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 04ba30b0db..3dbef4406e 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 f0ed714350..d9101e0b34 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 796e1dd7f6f5879f7234e24d71f69ce7ffc8633b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:23 +0200 Subject: [PATCH 13/29] 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> Signed-off-by: William Phetsinorath Change-Id: I94b7b2df3d455f9791f0385aab46532b6a6a6964 --- 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 3dbef4406e..230dbcb6b8 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 a6352f96057fc414e4ab7affdcdd2fc08fbd4959 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:23 +0200 Subject: [PATCH 14/29] 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 d9101e0b34..9926e3e4ae 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. `project.delete` results are * not written here: at emit time the row is not yet archived, and a success must 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 ebd0f993cb4746063f33ac91baabb0a9c242409f Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:24 +0200 Subject: [PATCH 15/29] 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> Signed-off-by: William Phetsinorath Change-Id: Iec8008c8736fa5e3f04fb661568a0ca86a6a6964 --- .../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 bddd9156e077a55775430ecf91ad7d83eb44cdfe Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:24 +0200 Subject: [PATCH 16/29] refactor(cluster): drop Kubeconfig import alias Co-authored-by: Automata Signed-off-by: Shikanime Deva <22115108+shikanime@users.noreply.github.com> Signed-off-by: William Phetsinorath Change-Id: I5094e976da1ceb3b103bfd6632a1c3256a6a6964 --- .../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 2c86e1a61cf3fc2d94e420533fc9319eb2d0be56 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:24 +0200 Subject: [PATCH 17/29] 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> Signed-off-by: William Phetsinorath Change-Id: Ia7d6c7881f1d0154f865469a29753a496a6a6964 --- .../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 9926e3e4ae..ec7191ad35 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 29e6b92b7115b8b7e4a253357e5828c25f7e7303 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:24 +0200 Subject: [PATCH 18/29] 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> Signed-off-by: William Phetsinorath Change-Id: I3b10511f56e3fec439d8911849e7d7cb6a6a6964 --- .../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 ec7191ad35..9cdbfa5a86 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 f2d463cce50b61d8db4118a5e2fe01dbba273089 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:25 +0200 Subject: [PATCH 19/29] 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> Signed-off-by: William Phetsinorath Change-Id: Id2bdb99c4b0e568d2375759fb67631a56a6a6964 --- .../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 575bbf5286c10a1ed99fc8edc474ed33cf1e1e10 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:25 +0200 Subject: [PATCH 20/29] 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> Signed-off-by: William Phetsinorath Change-Id: I1542fb0092739ab8f3f99de552b96ff16a6a6964 --- .../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 8bdda449af039e6c3ca48dbd720d7a2bb6b069d9 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:25 +0200 Subject: [PATCH 21/29] fix(cluster): keep makeCluster factory module-local Isolation: cluster-testing.utils no longer imports and re-exports environment's makeCluster; the module owns its factory again. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I7ee65d163430af7a67e47f2519076aee6a6a6964 --- .../modules/cluster/cluster-queries.utils.ts | 68 +++++++++---------- .../src/modules/cluster/cluster.controller.ts | 10 +-- .../src/modules/cluster/cluster.service.ts | 8 +-- 3 files changed, 43 insertions(+), 43 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.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, 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 dc44375a4e4e929dd770ed88e5dde9ea9ce15fed Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:26 +0200 Subject: [PATCH 22/29] fix(cluster): port argocd handlers onto the renamed client methods Main renamed VaultClientService.upsertKvData to ensureKvData and GitlabClientService.getOrCreateInfraGroupRepo to ensureInfraGroupRepo; the argocd handlers kept the old names and broke the docker build on the merge ref. Also inline CLUSTER_PUBLIC into its use sites. Signed-off-by: William Phetsinorath Change-Id: Idb33c626698fd9df3aefee2c79b877e76a6a6964 --- apps/server-nestjs/src/modules/argocd/argocd.service.ts | 4 ++-- apps/server-nestjs/src/modules/cluster/cluster.service.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 230dbcb6b8..fc144e8a3e 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -84,7 +84,7 @@ export class ArgoCDService { 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( + await this.vault.ensureKvData( `zone-${cluster.zone.slug}`, `clusters/cluster-${cluster.label}/argocd-cluster-secret`, { data: generateClusterSecretData(cluster, kubeconfig) }, @@ -119,7 +119,7 @@ export class ArgoCDService { } private async commitZoneValues(zoneSlug: string) { - const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + const infraProject = await this.gitlab.ensureInfraGroupRepo(zoneSlug) const clusters = await this.datastore.getZoneClusterNames(zoneSlug) const vaultValues = await this.generateZoneVaultValues(zoneSlug) const action = await this.gitlab.generateCreateOrUpdateAction( diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 40eb46b8ce..11e64e3675 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -29,8 +29,6 @@ import { updateCluster, } from './cluster-queries.utils' -const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public - @Injectable() export class ClusterService { constructor( @@ -72,7 +70,7 @@ export class ClusterService { const clusterCreated = await this.prisma.$transaction(async (tx) => { const clusterCreated = await createCluster(tx, clusterData, kubeconfig, zoneId) - if (data.privacy !== CLUSTER_PUBLIC && projectIds?.length) { + if (data.privacy !== ClusterPrivacySchema.enum.public && projectIds?.length) { await linkClusterToProjects(tx, clusterCreated.id, projectIds) } @@ -102,7 +100,7 @@ export class ClusterService { if (!dbCluster) throw new NotFoundException('Cluster not found') const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data - const publicProjectIds = data.privacy === CLUSTER_PUBLIC ? undefined : projectIds + const publicProjectIds = data.privacy === ClusterPrivacySchema.enum.public ? undefined : projectIds await this.prisma.$transaction(async (tx) => { const clusterUpdated = await updateCluster(tx, clusterId, clusterData, kubeconfig) From 1a244caaa9568d0bd1d1d04559b459f5eed69c3c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:26 +0200 Subject: [PATCH 23/29] fix(server-nestjs): keep AdminTokenModule registered alongside ClusterModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier drop (905d4c4a) assumed a stacked branch would own the admin-token registration, but that PR (#2740) merged into main before this branch was rebuilt — merging as-is removed the /api/v1/admin/tokens endpoints from the Nest app and its Swagger. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ia1c33b7e4d2f49108c5e6a7d15b4d9e2f3a4b5c6 --- apps/server-nestjs/src/main.module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index dc85db920d..b2b2189026 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,6 +4,7 @@ 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' @@ -24,8 +25,8 @@ import { RepositoryModule } from './modules/repository/repository.module' import { StageModule } from './modules/stage/stage.module' import { SystemConfigModule } from './modules/system-config/system-config.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' -import { UserModule } from './modules/user/user.module' import { UserTokensModule } from './modules/user-tokens/user-tokens.module' +import { UserModule } from './modules/user/user.module' import { VersionModule } from './modules/version/version.module' import { ZoneModule } from './modules/zone/zone.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -39,6 +40,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' }), TerminusModule.forRoot(), AdminRoleModule, + AdminTokenModule, AuthModule, ClusterModule, DeploymentModule, From a877d9dbf58bdcf72b43512187f3dc7bdfcfd91c Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:26 +0200 Subject: [PATCH 24/29] fix(cluster): validate the PUT clusterId and preserve dedicated project links Add ParseUUIDPipe to update so every parametrized route rejects a malformed id with 400 before reaching Prisma, and stop syncClusterProjectLinks from detaching projects when a PUT body omits projectIds: removals are computed only when the field was actually sent, an explicit [] still detaches all, and a flip to public still unlinks everything. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ib2d44c8f5e3a4051768d9e2f3a4b5c7a8b9c0d1e --- .../modules/cluster/cluster-queries.utils.ts | 6 ++- .../cluster/cluster.controller.spec.ts | 24 ++++++++++ .../src/modules/cluster/cluster.controller.ts | 2 +- .../modules/cluster/cluster.service.spec.ts | 46 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 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 d029750aed..79cf724608 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -239,9 +239,13 @@ export async function syncClusterProjectLinks( return } + // Legacy parity: project removals are only computed when the caller actually + // sent projectIds. An update that omits the field must keep every existing + // link, while an explicit [] detaches all of them. + if (projectIds === undefined) return const dbProjects = await getProjectsByClusterId(tx, clusterId) const dbProjectIds = dbProjects?.map(project => project.id) ?? [] - for (const projectId of dbProjectIds.filter(dbProjectId => !projectIds?.includes(dbProjectId))) { + for (const projectId of dbProjectIds.filter(dbProjectId => !projectIds.includes(dbProjectId))) { await removeClusterFromProject(tx, clusterUpdated.id, projectId) } } 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 a2793fe262..e1db31ac9e 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -74,6 +74,30 @@ describe('clusterController', () => { }) }) + it.each<[method: 'GET' | 'PUT' | 'DELETE', url: string]>([ + ['GET', '/api/v1/clusters/not-a-uuid'], + ['GET', '/api/v1/clusters/usage/not-a-uuid'], + ['GET', '/api/v1/clusters/not-a-uuid/environments'], + ['PUT', '/api/v1/clusters/not-a-uuid'], + ['DELETE', '/api/v1/clusters/not-a-uuid'], + ])('rejects a malformed clusterId on %s %s with 400', async (method, url) => { + const requestor: UserContext = { userId, adminPermissions: ADMIN_PERMS.MANAGE, userType: 'human' } + auth.authenticate.mockResolvedValue(requestor) + + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + const response = await app.getHttpAdapter().getInstance().inject({ + method, + url, + payload: method === 'PUT' ? {} : undefined, + }) + await app.close() + + expect(response.statusCode).toBe(400) + expect(service.updateCluster).not.toHaveBeenCalled() + expect(service.deleteCluster).not.toHaveBeenCalled() + }) + it('maps raw list records to the contract shape', async () => { const record = makeClusterListRecord({ infos: null }) service.listClustersForUser.mockResolvedValue([record]) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index b403044bbe..250194977f 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -59,7 +59,7 @@ export class ClusterController { @RequireAdminPermission('ManageClusters') @HttpCode(HttpStatus.OK) async update( - @Param('clusterId') clusterId: string, + @Param('clusterId', ParseUUIDPipe) clusterId: string, @Body(new ZodValidationPipe(UpdateClusterBodySchema)) data: UpdateClusterBody, @AuthUser() user: UserContext, @Req() request: FastifyRequest, 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 d5d29557a0..5c11836e8f 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,7 @@ import type { ConfigType } from '@nestjs/config' +import type { Cluster, Project } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterDetailsRecord } from './cluster-queries.utils' import { ADMIN_PERMS } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { UnprocessableEntityException } from '@nestjs/common' @@ -11,6 +13,7 @@ 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 { makeProject } from '../project/project-testing.utils' import { makeCluster, makeClusterDetailsRecord, @@ -245,4 +248,47 @@ describe('clusterService', () => { await expect(service.updateCluster({ infos: 'x' }, record.id, 'u', 'r')) .rejects.toThrow(new UnprocessableEntityException('Echec des services à la mise à jour du cluster')) }) + + it('keeps dedicated project links when an update omits projectIds', async () => { + const record: Cluster & { projects: Project[] } = { ...makeClusterDetailsRecord({ privacy: 'dedicated' }), projects: [makeProject()] } + prisma.cluster.findUnique.mockResolvedValue(record) + prisma.cluster.update.mockResolvedValue(record) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(record) + + await service.updateCluster({ infos: 'updated' }, record.id, 'u', 'r') + + // Only the field update ran: no per-project disconnect when the body omits + // projectIds (legacy parity). + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + }) + + it('detaches dedicated project links on an explicit empty projectIds', async () => { + const record = makeClusterDetailsRecord({ privacy: 'dedicated' }) + const dbCluster: Cluster & { projects: Project[] } = { ...record, projects: [makeProject({ id: 'project-1' })] } + prisma.cluster.findUnique.mockResolvedValue(record) + prisma.cluster.update.mockResolvedValue(record) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(dbCluster) + + await service.updateCluster({ infos: 'updated', projectIds: [] }, record.id, 'u', 'r') + + expect(prisma.cluster.update).toHaveBeenLastCalledWith(expect.objectContaining({ + data: expect.objectContaining({ projects: { disconnect: { id: 'project-1' } } }), + })) + }) + + it('detaches all projects when an update flips the cluster to public', async () => { + const record = makeClusterDetailsRecord({ privacy: 'dedicated' }) + const publicRecord: ClusterDetailsRecord = { ...record, privacy: 'public' } + const dbCluster: Cluster & { projects: Project[] } = { ...record, projects: [makeProject({ id: 'project-1' }), makeProject({ id: 'project-2' })] } + prisma.cluster.findUnique.mockResolvedValue(record) + prisma.cluster.update.mockResolvedValueOnce(publicRecord) + prisma.cluster.findUniqueOrThrow.mockResolvedValue(dbCluster) + + await service.updateCluster({ privacy: 'public' }, record.id, 'u', 'r') + + expect(prisma.cluster.update).toHaveBeenCalledTimes(3) + expect(prisma.cluster.update).toHaveBeenLastCalledWith(expect.objectContaining({ + data: expect.objectContaining({ projects: { disconnect: { id: 'project-2' } } }), + })) + }) }) From 1e06cca2eadd69d26a4054e5694d97d6156cc8c2 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:26 +0200 Subject: [PATCH 25/29] fix(argocd): restore legacy cluster-sync parity on delete and secret paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize the Vault cluster-secret config field as JSON instead of YAML (the ArgoCD cluster secret expects a JSON string, as the legacy plugin did), look zone clusters up by slug instead of passing the slug to a UUID lookup, and drop the cluster being deleted from the zone values while its row still exists — the service removes it only after this listener succeeds. Cover the secret shape and the zone lookup with regression tests, including the datastore query itself, and hoist the shared cluster factory to module scope so SonarQube's new-code analysis stops flagging it. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ic3d55e9a6b4c4f1839e0f2a4b5c7a8b9c1d2e3f4 --- .../argocd/argocd-datastore.service.spec.ts | 55 +++++++++++++++++++ .../argocd/argocd-datastore.service.ts | 9 ++- .../src/modules/argocd/argocd.service.spec.ts | 42 ++++++++++++++ .../src/modules/argocd/argocd.service.ts | 6 +- .../src/modules/argocd/argocd.utils.ts | 5 +- 5 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 apps/server-nestjs/src/modules/argocd/argocd-datastore.service.spec.ts diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.spec.ts new file mode 100644 index 0000000000..3b88db1699 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.spec.ts @@ -0,0 +1,55 @@ +import type { TestingModule } from '@nestjs/testing' +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 { PrismaService } from '../infrastructure/database/prisma.service' +import { makeZone } from '../zone/zone-testing.utils' +import { ArgoCDDatastoreService } from './argocd-datastore.service' + +function makeZoneWithClusters(labels: string[]) { + return { ...makeZone(), clusters: labels.map(label => ({ label })) } +} + +describe('argoCDDatastoreService', () => { + let service: ArgoCDDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ArgoCDDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(ArgoCDDatastoreService) + }) + + describe('getZoneClusterNames', () => { + it('queries the zone by slug', async () => { + prisma.zone.findUnique.mockResolvedValue(makeZoneWithClusters(['review-cluster'])) + + await service.getZoneClusterNames('zone-review') + + expect(prisma.zone.findUnique).toHaveBeenCalledWith({ + where: { slug: 'zone-review' }, + select: { clusters: { select: { label: true } } }, + }) + }) + + it('excludes the deleted cluster label', async () => { + prisma.zone.findUnique.mockResolvedValue(makeZoneWithClusters(['review-cluster', 'other-cluster'])) + + await expect(service.getZoneClusterNames('zone-review', 'review-cluster')).resolves.toEqual(['other-cluster']) + }) + + it('returns no names when the zone is unknown', async () => { + prisma.zone.findUnique.mockResolvedValue(null) + + await expect(service.getZoneClusterNames('missing-zone')).resolves.toEqual([]) + }) + }) +}) 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 692bae93cc..e7ab15df55 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -157,12 +157,15 @@ export class ArgoCDDatastoreService { }) } - async getZoneClusterNames(zoneId: string): Promise { + // Runs while the cluster row still exists (the service deletes it only after + // this listener succeeds), so it must be excluded explicitly — legacy parity: + // hook-wrapper removed the deleted label from zone.clusterNames. + async getZoneClusterNames(zoneSlug: string, excludedClusterLabel?: string): Promise { const zones = await this.prisma.zone.findUnique({ - where: { id: zoneId }, + where: { slug: zoneSlug }, select: { clusters: { select: { label: true } } }, }) - return zones?.clusters.map(({ label }) => label) ?? [] + return zones?.clusters.map(({ label }) => label).filter(label => label !== excludedClusterLabel) ?? [] } async getZoneSlug(zoneId: string): Promise { diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index cdebe602e4..31f8b098a1 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,6 +1,8 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone } from './argocd-datastore.service' import { generateNamespaceName } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' @@ -8,6 +10,7 @@ import { parse, stringify } from 'yaml' import { argocdConfigFactory } from '../../config/argocd.config' import { baseConfigFactory } from '../../config/base.config' import { vaultConfigFactory } from '../../config/vault.config' +import { makeKubeconfig } from '../cluster/cluster-testing.utils' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -22,6 +25,17 @@ import { makeProjectWithDetails, } from './argocd-testing.utils' import { ArgoCDService } from './argocd.service' +import { generateClusterSecretData } from './argocd.utils' + +function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + return { + label: 'review-cluster', + clusterResources: true, + kubeconfig: makeKubeconfig(), + zone: { id: faker.string.uuid(), slug: 'zone-review' }, + ...overrides, + } +} describe('argoCDService', () => { let service: ArgoCDService @@ -855,4 +869,32 @@ describe('argoCDService', () => { 'repo2', ]) }) + + describe('cluster events', () => { + it('serializes the argocd cluster secret config field as JSON', () => { + const secret = generateClusterSecretData( + { label: 'review-cluster', clusterResources: true }, + { cluster: { tlsServerName: 'example.com' }, user: { token: 'tok' } }, + ) + + const config = JSON.parse(secret.config) + expect(config.bearerToken).toBe('tok') + expect(config.tlsClientConfig.serverName).toBe('example.com') + }) + + it('passes the zone slug and excludes the deleted cluster label from the values', async () => { + datastore.getCluster.mockResolvedValue(makeClusterWithZone()) + datastore.getZoneClusterNames.mockResolvedValue(['review-cluster']) + datastore.getZoneSlug.mockResolvedValue(null) + gitlab.ensureInfraGroupRepo.mockResolvedValue(makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' })) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + vault.getAuthApproleRoleRoleId.mockRejectedValue(new Error('missing')) + vault.ensureAuthApproleRoleSecretId.mockRejectedValue(new Error('missing')) + + const result = await service.handleClusterDelete({ clusterId: faker.string.uuid() }) + + expect(result.argocd?.status).toBe('OK') + expect(datastore.getZoneClusterNames).toHaveBeenCalledWith('zone-review', 'review-cluster') + }) + }) }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index fc144e8a3e..a3a497174a 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -114,13 +114,13 @@ export class ArgoCDService { `zone-${cluster.zone.slug}`, `clusters/cluster-${cluster.label}/argocd-cluster-secret`, ) - await this.commitZoneValues(cluster.zone.slug) + await this.commitZoneValues(cluster.zone.slug, cluster.label) this.logger.log(`ArgoCD cluster cleanup completed for ${cluster.label}`) } - private async commitZoneValues(zoneSlug: string) { + private async commitZoneValues(zoneSlug: string, excludedClusterLabel?: string) { const infraProject = await this.gitlab.ensureInfraGroupRepo(zoneSlug) - const clusters = await this.datastore.getZoneClusterNames(zoneSlug) + const clusters = await this.datastore.getZoneClusterNames(zoneSlug, excludedClusterLabel) const vaultValues = await this.generateZoneVaultValues(zoneSlug) const action = await this.gitlab.generateCreateOrUpdateAction( infraProject, diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts index 687a5150fe..355fb97063 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -1,5 +1,4 @@ import type { Kubeconfig } from '@cpn-console/shared' -import { stringify } from 'yaml' export function generateClusterTlsClientConfig(kubeconfig: Kubeconfig) { return { @@ -30,6 +29,8 @@ export function generateClusterSecretData(cluster: { label: string, clusterResou name: cluster.label, clusterResources: String(cluster.clusterResources), server: kubeconfig.cluster.server, - config: stringify(generateClusterTlsClientConfig(kubeconfig)), + // ArgoCD's cluster-secret `config` field is a JSON string (legacy parity: + // the plugin fed JSON.stringify(convertConfig(cluster)) to Vault). + config: JSON.stringify(generateClusterTlsClientConfig(kubeconfig)), } } From b6a8add69f081472489db0d1f3ceebb10ca827b6 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:27 +0200 Subject: [PATCH 26/29] fix(cluster): serve the contract 204 status on cluster deletion Nest answered 200 on DELETE /api/v1/clusters/:clusterId while the legacy router and clusterContract.deleteCluster announce 204, breaking the client's extractData(response, 204) success path. Cover the status through HTTP with and without force. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I6502049ce6838b906c8ca5234a8d6f186a6a6964 --- .../cluster/cluster.controller.spec.ts | 20 +++++++++++++++++++ .../src/modules/cluster/cluster.controller.ts | 1 + 2 files changed, 21 insertions(+) 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 e1db31ac9e..4c94a6c2c9 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -217,4 +217,24 @@ describe('clusterController', () => { 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') }) + + it.each<[query: string]>([ + [''], + ['?force=true'], + ])('returns the contract status 204 on a successful HTTP DELETE%s', async (query) => { + service.deleteCluster.mockResolvedValue(0) + auth.authenticate.mockResolvedValue({ userId, adminPermissions: ADMIN_PERMS.MANAGE, userType: 'human' }) + const clusterId = faker.string.uuid() + + const app = module.createNestApplication(new FastifyAdapter()) + await app.init() + const response = await app.getHttpAdapter().getInstance().inject({ + method: 'DELETE', + url: `/api/v1/clusters/${clusterId}${query}`, + }) + await app.close() + + expect(response.statusCode).toBe(204) + expect(service.deleteCluster).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts index 250194977f..b276bcf7d7 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.controller.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -69,6 +69,7 @@ export class ClusterController { } @Delete(':clusterId') + @HttpCode(HttpStatus.NO_CONTENT) @RequireAdminPermission('ManageClusters') async delete( @Param('clusterId', ParseUUIDPipe) clusterId: string, From 6033fcb675d5db2a02419c2b824a1c8c917af02b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:27 +0200 Subject: [PATCH 27/29] fix(argocd): fail cluster sync when zone vault credentials are unavailable generateZoneVaultValues swallowed role-id/secret-id errors and committed 'none' placeholders, letting a cluster upsert/delete overwrite the whole zone's functional AppRole credentials. Let the error propagate so capturePluginResult returns KO and ClusterService answers 422 without rewriting argocd-values.yaml (legacy hook parity). Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ie02095fa827ca9275a50467a4dc90c586a6a6964 --- .../src/modules/argocd/argocd.service.spec.ts | 22 +++++++++++++++++-- .../src/modules/argocd/argocd.service.ts | 14 +++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index 31f8b098a1..a405c8e926 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -888,13 +888,31 @@ describe('argoCDService', () => { datastore.getZoneSlug.mockResolvedValue(null) gitlab.ensureInfraGroupRepo.mockResolvedValue(makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' })) gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) - vault.getAuthApproleRoleRoleId.mockRejectedValue(new Error('missing')) - vault.ensureAuthApproleRoleSecretId.mockRejectedValue(new Error('missing')) const result = await service.handleClusterDelete({ clusterId: faker.string.uuid() }) expect(result.argocd?.status).toBe('OK') expect(datastore.getZoneClusterNames).toHaveBeenCalledWith('zone-review', 'review-cluster') }) + + it.each<[field: 'role-id' | 'secret-id']>([ + ['role-id'], + ['secret-id'], + ])('does not commit zone values when the %s cannot be fetched', async (field) => { + datastore.getCluster.mockResolvedValue(makeClusterWithZone()) + if (field === 'role-id') { + vault.getAuthApproleRoleRoleId.mockRejectedValue(new Error('missing')) + vault.ensureAuthApproleRoleSecretId.mockResolvedValue('valid-secret') + } else { + vault.getAuthApproleRoleRoleId.mockResolvedValue('valid-role') + vault.ensureAuthApproleRoleSecretId.mockRejectedValue(new Error('missing')) + } + + const result = await service.handleClusterUpsert({ clusterId: faker.string.uuid() }) + + expect(result.argocd?.status).toBe('KO') + expect(result.argocd?.message).toBe('missing') + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index a3a497174a..2023e540e9 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -136,14 +136,12 @@ export class ArgoCDService { } 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.ensureAuthApproleRoleSecretId(`zone-${zoneSlug}`).catch(() => { - this.logger.warn(`Couldn't generate zone app role secret (zone=${zoneSlug})`) - return undefined - }) + // Propagate failures instead of falling back to `none`: AppRole is required + // to read cluster secrets (#1833), and committing placeholder credentials + // would overwrite the functional ones for the whole zone. Legacy parity: + // the error reaches the ArgoCD hook catch, which returns KO (→ API 422). + const roleId = await this.vault.getAuthApproleRoleRoleId(`zone-${zoneSlug}`) + const secretId = await this.vault.ensureAuthApproleRoleSecretId(`zone-${zoneSlug}`) return generateZoneVaultValues(this.vaultConfig.url, zoneSlug, roleId, secretId) } From 83bdc693338d9e623ad72d3a9902d86bb1636fea Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:27 +0200 Subject: [PATCH 28/29] style(events): drop extra blank line in app-events.service Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: Ic17a1d1d863599216f49082fc2a8a9fe6a6a6964 --- apps/server-nestjs/src/modules/events/app-events.service.ts | 1 - 1 file changed, 1 deletion(-) 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 9cdbfa5a86..8d76b32b2c 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -55,7 +55,6 @@ export interface ClusterEventPayload { zoneId?: string } - /** Admin-log action labels (legacy hooks wording). */ export type EventLogAction = | 'Create Project' | 'Update Project' | 'Delete all project resources' From d60a3b68817707e0b3d589671372177f0b1dcc55 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 21 Oct 2026 17:36:27 +0200 Subject: [PATCH 29/29] refactor(argocd,cluster): dedupe factory, inline privacy enum, compose transactions Move the argocd-spec-local makeClusterWithZone factory into argocd-testing.utils. Inline ClusterPrivacySchema.enum access at the cluster-queries call sites, dropping the CLUSTER_PUBLIC and CLUSTER_DEDICATED aliases. Extract createClusterWithLinks and updateClusterWithLinks into cluster-queries.utils so the service keeps only the $transaction boundary and the emit-and-throw, matching the admin-role pattern. Drop legacy-parity clauses from this branch's added comments; keep the load-bearing invariants. Co-authored-by: Automata Signed-off-by: William Phetsinorath Change-Id: I92452f42ed4263923e6aac5d4a9f05ef6a6a6964 --- .../argocd/argocd-datastore.service.ts | 3 +- .../modules/argocd/argocd-testing.utils.ts | 14 +++++- .../src/modules/argocd/argocd.service.spec.ts | 13 +---- .../src/modules/argocd/argocd.utils.ts | 2 - .../modules/cluster/cluster-queries.utils.ts | 47 +++++++++++++---- .../modules/cluster/cluster.service.spec.ts | 4 +- .../src/modules/cluster/cluster.service.ts | 50 ++++--------------- .../src/modules/events/app-events.service.ts | 6 +-- 8 files changed, 65 insertions(+), 74 deletions(-) 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 e7ab15df55..6cfa416e64 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -158,8 +158,7 @@ export class ArgoCDDatastoreService { } // Runs while the cluster row still exists (the service deletes it only after - // this listener succeeds), so it must be excluded explicitly — legacy parity: - // hook-wrapper removed the deleted label from zone.clusterNames. + // this listener succeeds), so it must be excluded explicitly. async getZoneClusterNames(zoneSlug: string, excludedClusterLabel?: string): Promise { const zones = await this.prisma.zone.findUnique({ where: { slug: zoneSlug }, diff --git a/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts index e24dc87c12..c03a8e11f6 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -1,6 +1,16 @@ -import type { ProjectWithDetails } from './argocd-datastore.service' - +import type { ClusterWithZone, ProjectWithDetails } from './argocd-datastore.service' import { faker } from '@faker-js/faker' +import { makeKubeconfig } from '../cluster/cluster-testing.utils' + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + return { + label: 'review-cluster', + clusterResources: true, + kubeconfig: makeKubeconfig(), + zone: { id: faker.string.uuid(), slug: 'zone-review' }, + ...overrides, + } +} export function makeProjectDeploymentSource( overrides: Partial = {}, diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index a405c8e926..6198fa9775 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,6 +1,5 @@ import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' -import type { ClusterWithZone } from './argocd-datastore.service' import { generateNamespaceName } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' @@ -10,12 +9,12 @@ import { parse, stringify } from 'yaml' import { argocdConfigFactory } from '../../config/argocd.config' import { baseConfigFactory } from '../../config/base.config' import { vaultConfigFactory } from '../../config/vault.config' -import { makeKubeconfig } from '../cluster/cluster-testing.utils' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' import { VaultClientService } from '../vault/vault-client.service' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { + makeClusterWithZone, makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, @@ -27,16 +26,6 @@ import { import { ArgoCDService } from './argocd.service' import { generateClusterSecretData } from './argocd.utils' -function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { - return { - label: 'review-cluster', - clusterResources: true, - kubeconfig: makeKubeconfig(), - zone: { id: faker.string.uuid(), slug: 'zone-review' }, - ...overrides, - } -} - describe('argoCDService', () => { let service: ArgoCDService let datastore: DeepMockProxy diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts index 355fb97063..a2862a9d23 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -29,8 +29,6 @@ export function generateClusterSecretData(cluster: { label: string, clusterResou name: cluster.label, clusterResources: String(cluster.clusterResources), server: kubeconfig.cluster.server, - // ArgoCD's cluster-secret `config` field is a JSON string (legacy parity: - // the plugin fed JSON.stringify(convertConfig(cluster)) to Vault). config: JSON.stringify(generateClusterTlsClientConfig(kubeconfig)), } } 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 79cf724608..f7f6e41690 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -1,10 +1,7 @@ -import type { Kubeconfig } from '@cpn-console/shared' +import type { CreateClusterBody, Kubeconfig, UpdateClusterBody } from '@cpn-console/shared' 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, label: true, @@ -102,7 +99,7 @@ export function generateClusterWhere(userId?: string): Prisma.ClusterWhereInput return userId ? { OR: [ - { privacy: CLUSTER_PUBLIC }, + { privacy: ClusterPrivacySchema.enum.public }, { projects: { some: { members: { some: { userId } } } } }, { projects: { some: { ownerId: userId } } }, { environments: { some: { project: { members: { some: { userId } } } } } }, @@ -227,11 +224,11 @@ export async function syncClusterProjectLinks( clusterId: string, projectIds: string[] | undefined, ) { - if (projectIds && clusterUpdated.privacy === CLUSTER_DEDICATED) { + if (projectIds && clusterUpdated.privacy === ClusterPrivacySchema.enum.dedicated) { await linkClusterToProjects(tx, clusterId, projectIds) } - if (clusterUpdated.privacy === CLUSTER_PUBLIC) { + if (clusterUpdated.privacy === ClusterPrivacySchema.enum.public) { const dbProjects = await getProjectsByClusterId(tx, clusterId) for (const projectId of dbProjects?.map(project => project.id) ?? []) { await removeClusterFromProject(tx, clusterUpdated.id, projectId) @@ -239,9 +236,9 @@ export async function syncClusterProjectLinks( return } - // Legacy parity: project removals are only computed when the caller actually - // sent projectIds. An update that omits the field must keep every existing - // link, while an explicit [] detaches all of them. + // Project removals run only when the caller actually sent projectIds: an + // update that omits the field keeps every existing link, while an explicit + // [] detaches all of them. if (projectIds === undefined) return const dbProjects = await getProjectsByClusterId(tx, clusterId) const dbProjectIds = dbProjects?.map(project => project.id) ?? [] @@ -254,6 +251,36 @@ export function deleteCluster(tx: Prisma.TransactionClient, id: string) { return tx.cluster.delete({ where: { id } }) } +export async function createClusterWithLinks(tx: Prisma.TransactionClient, data: CreateClusterBody) { + const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data + + const clusterCreated = await createCluster(tx, clusterData, kubeconfig, zoneId) + + if (data.privacy !== ClusterPrivacySchema.enum.public && projectIds?.length) { + await linkClusterToProjects(tx, clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(tx, clusterCreated.id, stageIds) + } + + return clusterCreated +} + +export async function updateClusterWithLinks(tx: Prisma.TransactionClient, clusterId: string, data: UpdateClusterBody) { + const { projectIds, stageIds, kubeconfig, zoneId, ...clusterData } = data + const publicProjectIds = data.privacy === ClusterPrivacySchema.enum.public ? undefined : projectIds + + const clusterUpdated = await updateCluster(tx, clusterId, clusterData, kubeconfig) + + if (zoneId) { + await linkZoneToClusters(tx, zoneId, [clusterId]) + } + + await syncClusterProjectLinks(tx, clusterUpdated, clusterId, publicProjectIds) + await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) +} + export function linkZoneToClusters(tx: Prisma.TransactionClient, zoneId: string, clusterIds: string[]) { return tx.zone.update({ where: { id: zoneId }, 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 5c11836e8f..4070bd0827 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -186,7 +186,7 @@ describe('clusterService', () => { ).rejects.toThrow('Cluster not found') }) - it('deletes a cluster after a successful reconcile, in the legacy order', async () => { + it('deletes a cluster after a successful reconcile, event before delete', async () => { const record = makeClusterListRecord() prisma.environment.findFirst.mockResolvedValue(null) prisma.cluster.delete.mockResolvedValue(record) @@ -258,7 +258,7 @@ describe('clusterService', () => { await service.updateCluster({ infos: 'updated' }, record.id, 'u', 'r') // Only the field update ran: no per-project disconnect when the body omits - // projectIds (legacy parity). + // projectIds. expect(prisma.cluster.update).toHaveBeenCalledTimes(1) }) diff --git a/apps/server-nestjs/src/modules/cluster/cluster.service.ts b/apps/server-nestjs/src/modules/cluster/cluster.service.ts index 11e64e3675..8d9af5bbb1 100644 --- a/apps/server-nestjs/src/modules/cluster/cluster.service.ts +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -6,27 +6,22 @@ import type { 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 { AdminAuthorized } from '@cpn-console/shared' 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, + createClusterWithLinks, deleteCluster, generateClusterWhere, getClusterById, getClusterByLabel, getClusterDetails, getClusterUsage, - linkClusterToProjects, - linkClusterToStages, - linkZoneToClusters, listClusterEnvironments, listClusters, - syncClusterProjectLinks, - syncClusterStageLinks, - updateCluster, + updateClusterWithLinks, } from './cluster-queries.utils' @Injectable() @@ -65,23 +60,9 @@ export class ClusterService { 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 this.prisma.$transaction(tx => createClusterWithLinks(tx, data)) - const clusterCreated = await this.prisma.$transaction(async (tx) => { - const clusterCreated = await createCluster(tx, clusterData, kubeconfig, zoneId) - - if (data.privacy !== ClusterPrivacySchema.enum.public && projectIds?.length) { - await linkClusterToProjects(tx, clusterCreated.id, projectIds) - } - - if (stageIds?.length) { - await linkClusterToStages(tx, clusterCreated.id, stageIds) - } - - return clusterCreated - }) - - await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId: clusterCreated.id, zoneId }, { + await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId: clusterCreated.id, zoneId: data.zoneId }, { action: 'Create Cluster', userId, requestId, @@ -99,19 +80,7 @@ export class ClusterService { 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 === ClusterPrivacySchema.enum.public ? undefined : projectIds - - await this.prisma.$transaction(async (tx) => { - const clusterUpdated = await updateCluster(tx, clusterId, clusterData, kubeconfig) - - if (zoneId) { - await linkZoneToClusters(tx, zoneId, [clusterId]) - } - - await syncClusterProjectLinks(tx, clusterUpdated, clusterId, publicProjectIds) - await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) - }) + await this.prisma.$transaction(tx => updateClusterWithLinks(tx, clusterId, data)) await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId, zoneId: dbCluster.zoneId }, { action: 'Update Cluster', @@ -135,10 +104,9 @@ export class ClusterService { }): 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. + // 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.emitClusterEventAndThrowOnFailure('cluster.delete', { clusterId }, { action: 'Delete Cluster', userId, 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 8d76b32b2c..bbc152e43f 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -140,9 +140,9 @@ export class AppEventsService { /** * 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). + * failure, like the emitZoneEvent consumers, and only emits the delete after + * every plugin cleaned up successfully (the caller must not have removed the + * row yet). */ async emitClusterEvent( event: ClusterEventName,