diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0fdc0dce02..defea2f3c1 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,8 +4,8 @@ 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' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -38,8 +38,8 @@ import { getDotenvPaths } from './utils/dotenv.utils' }), TerminusModule.forRoot(), AdminRoleModule, - AdminTokenModule, AuthModule, + ClusterModule, DeploymentModule, EnvironmentModule, HealthzModule, diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts index 407933a2d0..692bae93cc 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -112,6 +112,27 @@ export type ProjectWithDetails = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> +export const clusterSelect = { + label: true, + clusterResources: true, + kubeconfig: { + select: { + cluster: true, + user: true, + }, + }, + zone: { + select: { + id: true, + slug: true, + }, + }, +} satisfies Prisma.ClusterSelect + +export type ClusterWithZone = Prisma.ClusterGetPayload<{ + select: typeof clusterSelect +}> + @Injectable() export class ArgoCDDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} @@ -128,4 +149,27 @@ export class ArgoCDDatastoreService { }) return zones.map(zone => zone.slug) } + + async getCluster(clusterId: string): Promise { + return this.prisma.cluster.findUnique({ + where: { id: clusterId }, + select: clusterSelect, + }) + } + + async getZoneClusterNames(zoneId: string): Promise { + const zones = await this.prisma.zone.findUnique({ + where: { id: zoneId }, + select: { clusters: { select: { label: true } } }, + }) + return zones?.clusters.map(({ label }) => label) ?? [] + } + + async getZoneSlug(zoneId: string): Promise { + const zone = await this.prisma.zone.findUnique({ + where: { id: zoneId }, + select: { slug: true }, + }) + return zone?.slug ?? null + } } diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index dfa94b2417..dd0001fe91 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.ensureAuthApproleRoleSecretId(`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..687a5150fe --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -0,0 +1,35 @@ +import type { Kubeconfig } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateClusterTlsClientConfig(kubeconfig: Kubeconfig) { + return { + ...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, + }, + } +} + +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(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 new file mode 100644 index 0000000000..d029750aed --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster-queries.utils.ts @@ -0,0 +1,272 @@ +import type { Kubeconfig } 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, + 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 }> + +export function getClusterById(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function listClusterEnvironments(tx: Prisma.TransactionClient, clusterId: string) { + return tx.environment.findMany({ + where: { clusterId }, + select: clusterEnvironmentsSelect, + }) +} + +export function getClusterDetails(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ + where: { id }, + select: clusterDetailsSelect, + }) +} + +export function getClusterByLabel(tx: Prisma.TransactionClient, label: string) { + return tx.cluster.findUnique({ where: { label } }) +} + +export function listClusters(tx: Prisma.TransactionClient, where: Prisma.ClusterWhereInput) { + return tx.cluster.findMany({ + where, + select: clusterListSelect, + }) +} + +export function generateClusterWhere(userId?: string): Prisma.ClusterWhereInput { + return userId + ? { + OR: [ + { privacy: CLUSTER_PUBLIC }, + { projects: { some: { members: { some: { userId } } } } }, + { projects: { some: { ownerId: userId } } }, + { environments: { some: { project: { members: { some: { userId } } } } } }, + ], + } + : {} +} + +export function getProjectsByClusterId(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }).then(cluster => cluster.projects) +} + +export function getStagesByClusterId(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }).then(cluster => cluster.stages) +} + +export function createCluster( + tx: Prisma.TransactionClient, + data: Omit, + kubeconfig: Pick, + zoneId: string, +) { + return tx.cluster.create({ + data: { + ...data, + kubeconfig: { + create: { + user: kubeconfig.user, + cluster: kubeconfig.cluster, + }, + }, + zone: { connect: { id: zoneId } }, + }, + }) +} + +export function updateCluster( + tx: Prisma.TransactionClient, + id: string, + data: Partial>, + kubeconfig?: Pick, +) { + return tx.cluster.update({ + where: { id }, + data: kubeconfig + ? { + ...data, + kubeconfig: { + update: { + user: kubeconfig.user, + cluster: kubeconfig.cluster, + }, + }, + } + : data, + }) +} + +export function linkClusterToProjects(tx: Prisma.TransactionClient, id: string, projectIds: string[]) { + return tx.cluster.update({ + where: { id }, + data: { + projects: { connect: projectIds.map(projectId => ({ id: projectId })) }, + }, + }) +} + +export function linkClusterToStages(tx: Prisma.TransactionClient, id: string, stageIds: string[]) { + return tx.cluster.update({ + where: { id }, + data: { + stages: { connect: stageIds.map(stageId => ({ id: stageId })) }, + }, + }) +} + +export function removeClusterFromProject(tx: Prisma.TransactionClient, id: string, projectId: string) { + return tx.cluster.update({ + where: { id }, + data: { + projects: { disconnect: { id: projectId } }, + }, + }) +} + +export function removeClusterFromStage(tx: Prisma.TransactionClient, id: string, stageId: string) { + return tx.cluster.update({ + where: { id }, + data: { + stages: { disconnect: { id: stageId } }, + }, + }) +} + +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 getStagesByClusterId(tx, clusterId) + for (const stage of dbStages ?? []) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(tx, clusterUpdated.id, stage.id) + } + } +} + +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(tx: Prisma.TransactionClient, id: string) { + return tx.cluster.delete({ where: { id } }) +} + +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 })) }, + }, + }) +} + +export async function getClusterUsage(tx: Prisma.TransactionClient, clusterId: string) { + const clusterUsage = await tx.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..709ee5fd24 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster-testing.utils.ts @@ -0,0 +1,109 @@ +import type { CleanedCluster, CreateClusterBody } from '@cpn-console/shared' +import type { Kubeconfig, Stage } from '@prisma/client' +import type { ClusterDetailsRecord, ClusterEnvironmentsRecord, ClusterListRecord } from './cluster-queries.utils' +import { faker } from '@faker-js/faker' +import { makeCluster } from '../environment/environment-testing.utils' +import { makeProjectMembers } from '../project-members/project-members-testing.utils' +import { makeUser } from '../project/project-testing.utils' + +export { makeCluster } + +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 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: [makeProjectMembers()], + }, + ...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 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 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 new file mode 100644 index 0000000000..a2793fe262 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.spec.ts @@ -0,0 +1,196 @@ +import type { TestingModule } from '@nestjs/testing' +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 { 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 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, + ], + }).compile() + + controller = module.get(ClusterController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + 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(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 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)).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 body = makeCreateClusterBody() + const record = makeClusterDetailsRecord() + const details = toClusterDetails(record) + service.createCluster.mockResolvedValue(record) + + 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 record = makeClusterDetailsRecord() + const details = toClusterDetails(record) + service.updateCluster.mockResolvedValue(record) + + 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(0) + + expect(await controller.delete(clusterId, { force: true }, user, request)).toBeNull() + expect(service.deleteCluster).toHaveBeenCalledWith({ + clusterId, + userId: user.userId, + requestId: request.id, + 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 new file mode 100644 index 0000000000..b403044bbe --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.controller.ts @@ -0,0 +1,88 @@ +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, + UpdateClusterBodySchema, +} from '@cpn-console/shared' +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' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ClusterService } from './cluster.service' +import { toClusterAssociatedEnvironments, toClusterDetails, toClusters } from './cluster.utils' + +@Controller('api/v1/clusters') +@UseGuards(UserGuard) +export class ClusterController { + constructor(@Inject(ClusterService) private readonly clusterService: ClusterService) {} + + @Get('') + async list(@AuthUser() user: UserContext): Promise { + return toClusters(await this.clusterService.listClustersForUser(user)) + } + + @Get(':clusterId') + @RequireAdminPermission('ListClusters') + async getDetails(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { + return toClusterDetails(await this.clusterService.getClusterDetailsRecord(clusterId)) + } + + @Get('usage/:clusterId') + @RequireAdminPermission('ListClusters') + getUsage(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { + return this.clusterService.getClusterUsage(clusterId) + } + + @Get(':clusterId/environments') + @RequireAdminPermission('ListClusters') + async getEnvironments(@Param('clusterId', ParseUUIDPipe) clusterId: string): Promise { + return toClusterAssociatedEnvironments(await this.clusterService.getClusterAssociatedEnvironments(clusterId)) + } + + @Post('') + @RequireAdminPermission('ManageClusters') + @HttpCode(HttpStatus.CREATED) + async create( + @Body(new ZodValidationPipe(CreateClusterBodySchema)) data: CreateClusterBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const record: ClusterDetailsRecord = await this.clusterService.createCluster(data, user.userId, request.id) + return toClusterDetails(record) + } + + @Put(':clusterId') + @RequireAdminPermission('ManageClusters') + @HttpCode(HttpStatus.OK) + async update( + @Param('clusterId') clusterId: string, + @Body(new ZodValidationPipe(UpdateClusterBodySchema)) data: UpdateClusterBody, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const record = await this.clusterService.updateCluster(data, clusterId, user.userId, request.id) + return toClusterDetails(record) + } + + @Delete(':clusterId') + @RequireAdminPermission('ManageClusters') + async delete( + @Param('clusterId', ParseUUIDPipe) clusterId: string, + @Query(new ZodValidationPipe(DeleteClusterQuerySchema)) { force }: DeleteClusterQuery, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + 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 new file mode 100644 index 0000000000..f28935eee0 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common' +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 { ClusterController } from './cluster.controller' +import { ClusterService } from './cluster.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ], + 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..d5d29557a0 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.spec.ts @@ -0,0 +1,248 @@ +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' +import { Test } from '@nestjs/testing' +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 { + makeCluster, + makeClusterDetailsRecord, + makeClusterListRecord, + makeCreateClusterBody, +} from './cluster-testing.utils' +import { ClusterService } from './cluster.service' + +describe('clusterService', () => { + let service: ClusterService + let prisma: DeepMockProxy + let events: DeepMockProxy + let baseConfig: DeepMockProxy> + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + prisma.$transaction.mockImplementation(async (cb: (tx: unknown) => unknown) => cb(prisma)) + events = mockDeep() + baseConfig = mockDeep>() + appEvents = mockDeep() + appEvents.emitClusterEvent.mockResolvedValue({}) + + const moduleRef = await Test.createTestingModule({ + providers: [ + ClusterService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: events }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = moduleRef.get(ClusterService) + }) + + it('lists raw cluster records for an admin', async () => { + const record = makeClusterListRecord({ infos: null }) + prisma.cluster.findMany.mockResolvedValue([record]) + + 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.listClustersForUser({ userId }) + + expect(prisma.cluster.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { OR: expect.any(Array) }, + })) + }) + + 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 cluster event', 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(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.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) + + 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 () => { + 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 cluster event', 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(appEvents.emitClusterEvent).toHaveBeenCalledWith('cluster.upsert', expect.objectContaining({ clusterId: record.id }), expect.any(Object)) + }) + + 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 after a successful reconcile, in the legacy order', async () => { + const record = makeClusterListRecord() + prisma.environment.findFirst.mockResolvedValue(null) + prisma.cluster.delete.mockResolvedValue(record) + + const forcedCount = await service.deleteCluster({ + clusterId: record.id, + userId: faker.string.uuid(), + requestId: faker.string.uuid(), + }) + + 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)) + }) + + it('rejects cluster deletion and keeps the row when a plugin reports KO', async () => { + const record = makeClusterListRecord() + prisma.environment.findFirst.mockResolvedValue(null) + appEvents.emitClusterEvent.mockResolvedValue({ gitlab: { status: 'KO', message: 'boom', executionTime: 1, error: new Error('boom') } }) + + 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 () => { + 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('propagates upsert reconcile failure as 422', async () => { + const record = makeClusterDetailsRecord() + 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.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 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 new file mode 100644 index 0000000000..40eb46b8ce --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.service.ts @@ -0,0 +1,171 @@ +import type { + ClusterUsage, + CreateClusterBody, + UpdateClusterBody, +} from '@cpn-console/shared' +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, 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, + generateClusterWhere, + getClusterById, + getClusterByLabel, + getClusterDetails, + getClusterUsage, + linkClusterToProjects, + linkClusterToStages, + linkZoneToClusters, + listClusterEnvironments, + listClusters, + syncClusterProjectLinks, + syncClusterStageLinks, + updateCluster, +} from './cluster-queries.utils' + +const CLUSTER_PUBLIC = ClusterPrivacySchema.enum.public + +@Injectable() +export class ClusterService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listClustersForUser(user: UserContext): Promise { + return this.listClusters(AdminAuthorized.ListClusters(user.adminPermissions) ? undefined : user.userId) + } + + private async listClusters(userId?: string): Promise { + const where = generateClusterWhere(userId) + return listClusters(this.prisma, where) + } + + async getClusterDetailsRecord(clusterId: string): Promise { + return getClusterDetails(this.prisma, clusterId) + } + + async getClusterUsage(clusterId: string): Promise { + return getClusterUsage(this.prisma, clusterId) + } + + async getClusterAssociatedEnvironments(clusterId: string): Promise { + return listClusterEnvironments(this.prisma, clusterId) + } + + async createCluster( + data: CreateClusterBody, + userId: string, + 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 this.prisma.$transaction(async (tx) => { + const clusterCreated = await createCluster(tx, clusterData, kubeconfig, zoneId) + + if (data.privacy !== CLUSTER_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 }, { + action: 'Create Cluster', + userId, + requestId, + }, 'Echec des services à la création du cluster') + + return this.getClusterDetailsRecord(clusterCreated.id) + } + + async updateCluster( + data: UpdateClusterBody, + clusterId: string, + userId: string, + requestId: string, + ): Promise { + 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) + + if (zoneId) { + await linkZoneToClusters(tx, zoneId, [clusterId]) + } + + await syncClusterProjectLinks(tx, clusterUpdated, clusterId, publicProjectIds) + await syncClusterStageLinks(tx, clusterUpdated, clusterId, stageIds) + }) + + await this.emitClusterEventAndThrowOnFailure('cluster.upsert', { clusterId, zoneId: dbCluster.zoneId }, { + action: 'Update Cluster', + userId, + requestId, + }, 'Echec des services à la mise à jour du cluster') + + return this.getClusterDetailsRecord(clusterId) + } + + async deleteCluster({ + clusterId, + userId, + requestId, + force, + }: { + clusterId: string + userId?: string + requestId: string + force?: boolean + }): 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.emitClusterEventAndThrowOnFailure('cluster.delete', { clusterId }, { + action: 'Delete Cluster', + userId, + requestId, + }, 'Echec des services à la suppression du cluster') + + let forcedCount = 0 + if (force && environment) { + const envs = await this.prisma.environment.deleteMany({ where: { clusterId } }) + forcedCount = envs.count + } + 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) + } + } +} 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..583e01eaa9 --- /dev/null +++ b/apps/server-nestjs/src/modules/cluster/cluster.utils.ts @@ -0,0 +1,39 @@ +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 toClusters(records: ClusterListRecord[]): CleanedCluster[] { + 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 { + 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/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 32e1e6d612..628bf32966 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -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. */ +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' @@ -57,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. */ @@ -129,6 +139,20 @@ export class AppEventsService { return this.emitAndLog(event, payload, payload.projectId, context) } + /** + * 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, + ): Promise { + return this.emitAndLog(event, payload, null, context) + } + // Emits a zone event. Zones have no project row: the log carries no project id // and the caller awaits the merged results to answer 422 on failure (legacy hooks // behavior on `POST`/`PUT`/`DELETE /zones`). @@ -165,7 +189,7 @@ export class AppEventsService { } /** - * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * Reflects the listeners' outcome on the project row: * any KO result marks the project `failed`; a fully successful upsert marks it * `created` and records the provisioning version. A successful `project.delete` * leaves the `archived` status set when the project was archived. diff --git a/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..4722bd9b94 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,9 @@ 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 +export type ClusterUsage = Zod.infer