From 0e67709a36f3607ab14944bc4bed8c7fb8ca8dee Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:41:27 +0200 Subject: [PATCH] feat(server-nestjs): add environment API v2 --- apps/nginx-strangler/conf.d/routing.conf | 10 + apps/server-nestjs/src/main.module.ts | 2 + .../environment-datastore.service.spec.ts | 181 +++++++++++++++++ .../environment-datastore.service.ts | 99 +++++++++ .../environment/environment-testing.utils.ts | 87 ++++++++ .../environment-validation.service.spec.ts | 170 ++++++++++++++++ .../environment-validation.service.ts | 141 +++++++++++++ .../environment/environment.constants.ts | 2 + .../environment.controller.spec.ts | 128 ++++++++++++ .../environment/environment.controller.ts | 73 +++++++ .../modules/environment/environment.module.ts | 18 ++ .../environment/environment.service.spec.ts | 190 ++++++++++++++++++ .../environment/environment.service.ts | 81 ++++++++ .../src/modules/events/app-events.service.ts | 1 + packages/shared/src/api-client.ts | 2 + packages/shared/src/contracts/environment.ts | 86 +++++++- packages/shared/src/schemas/environment.ts | 16 ++ 17 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment-validation.service.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.constants.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.controller.spec.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.controller.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.module.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/environment/environment.service.ts diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index b481a38eeb..25bf6f7192 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -79,6 +79,16 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── location /api/ { proxy_pass http://server-legacy; diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 0bc0e5a91d..274a243a57 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule' import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { LogModule } from './modules/log/log.module' @@ -31,6 +32,7 @@ import { VersionModule } from './modules/version/version.module' ProjectRolesModule, LogModule, DeploymentModule, + EnvironmentModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..7b6857da84 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +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 { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getEnvironmentById', () => { + it('should return an environment with its cluster', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.findUnique.mockResolvedValue(environment) + + const result = await service.getEnvironmentById('env1') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { id: 'env1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..5e7e00fbff --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getEnvironmentById(environmentId: string): Promise { + return this.prisma.environment.findUnique({ + where: { id: environmentId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..8be999a81b --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,170 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..55c7690fb8 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,141 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async getOverflowResources({ request, limit, where }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources(where) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/apps/server-nestjs/src/modules/environment/environment.constants.ts b/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/environment/environment.controller.ts b/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/apps/server-nestjs/src/modules/environment/environment.module.ts b/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..49338c6fef --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,190 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getEnvironmentById.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment does not exist', async () => { + datastore.getEnvironmentById.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should reject when the environment belongs to another project', async () => { + datastore.getEnvironmentById.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId })) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getEnvironmentById.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getEnvironmentById.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment belongs to another project', async () => { + datastore.getEnvironmentById.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId })) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/environment/environment.service.ts b/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..e98f0adeb9 --- /dev/null +++ b/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getEnvironmentById(environmentId) + if (environment?.projectId !== projectId) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} 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 e989391e18..0c0aa94e03 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -23,6 +23,7 @@ export type EventLogAction | 'Replay hooks for Project' | 'Upsert Project Role' | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' export interface EventContext { diff --git a/packages/shared/src/api-client.ts b/packages/shared/src/api-client.ts index 96532b6a4d..c842cb9e99 100644 --- a/packages/shared/src/api-client.ts +++ b/packages/shared/src/api-client.ts @@ -2,6 +2,7 @@ import type { ApiFetcher } from '@ts-rest/core' import { initClient, initContract } from '@ts-rest/core' export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' export const contractInstance: ReturnType = initContract() @@ -13,6 +14,7 @@ export async function getContract() { ServiceChains: (await import('./contracts/index.js')).serviceChainContract, Deployments: (await import('./contracts/index.js')).deploymentContract, Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, Logs: (await import('./contracts/index.js')).logContract, PersonalAccessTokens: (await import('./contracts/index.js')) .personalAccessTokenContract, diff --git a/packages/shared/src/contracts/environment.ts b/packages/shared/src/contracts/environment.ts index ffa5de1c44..d7e44afe8f 100644 --- a/packages/shared/src/contracts/environment.ts +++ b/packages/shared/src/contracts/environment.ts @@ -1,8 +1,10 @@ import type { ClientInferRequest } from '@ts-rest/core' import { z } from 'zod' -import { apiPrefix, contractInstance } from '../api-client.js' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' import { + CreateEnvironmentSchema, EnvironmentSchema, + UpdateEnvironmentSchema, } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' @@ -88,3 +90,85 @@ export const environmentContract = contractInstance.router({ export type CreateEnvironmentBody = ClientInferRequest['body'] export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: null, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/packages/shared/src/schemas/environment.ts b/packages/shared/src/schemas/environment.ts index 4bbc0da1e6..f3c9bd2d8c 100644 --- a/packages/shared/src/schemas/environment.ts +++ b/packages/shared/src/schemas/environment.ts @@ -21,4 +21,20 @@ export const EnvironmentSchema = z.object({ autosync: z.boolean(), }).extend(AtDatesToStringExtend) +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer