diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index 1245ffdc4c..b481a38eeb 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -70,7 +70,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location /api/v1/deployments { + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { proxy_pass http://server-nestjs; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index f7234200d0..c0b88b7ab6 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -72,6 +72,63 @@ describe('argoCDService', () => { expect(service).toBeDefined() }) + describe('handleArgoCDProjectUpdate', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.readProjectValues.mockResolvedValue({}) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleArgoCDProjectUpdate(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleArgoCDProjectUpdate(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + it('should sync project environments', async () => { const mockProject = makeProjectWithDetails({ slug: 'project-1', diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index ad2fd374fb..132944e047 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,4 +1,5 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { RequiredServiceResult } from '../plugin/plugin.utils' import type { ProjectWithDetails } from './argocd-datastore.service' import { createHmac } from 'node:crypto' import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' @@ -36,6 +37,40 @@ export class ArgoCDService { this.logger.log('ArgoCDService initialized') } + @OnEvent('project.argocd.update') + @StartActiveSpan() + async handleArgoCDProjectUpdate(project: ProjectWithDetails): Promise> { + const start = process.hrtime.bigint() + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a argocd project update event for ${project.slug}`) + try { + await this.ensureProject(project) + const end = process.hrtime.bigint() + const executionTime = Number(end - start) / 1_000_000 + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + return { + argocd: { + status: 'OK', + message: 'Up to date', + executionTime, + }, + } + } catch (error: unknown) { + const end = process.hrtime.bigint() + const executionTime = Number(end - start) / 1_000_000 + this.logger.error(`ArgoCD sync failed for project ${project.slug}`, error) + return { + argocd: { + error, + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime, + }, + } + } + } + @OnEvent('project.upsert') @StartActiveSpan() async handleUpsert(project: ProjectWithDetails) { @@ -220,7 +255,7 @@ export class ArgoCDService { 'environment.id': environment.id, 'environment.name': environment.name, }) - const vaultValues = await this.vault.readProjectValues(project.id) ?? {} + const vaultValues = await this.vault.readProjectValues(project.slug) ?? {} const cluster = environment.cluster if (!cluster) { this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) @@ -230,11 +265,6 @@ export class ArgoCDService { const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) - const repo = project.repositories.find(r => r.isInfra) - if (!repo) { - this.logger.warn(`Infrastructure repository not found for project ${project.slug} (projectId=${project.id})`) - return null - } const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` const values = formatValues({ @@ -297,7 +327,7 @@ export class ArgoCDService { 'environment.id': environment.id, 'environment.name': environment.name, }) - const vaultValues = await this.vault.readProjectValues(project.id) ?? {} + const vaultValues = await this.vault.readProjectValues(project.slug) ?? {} const cluster = environment.cluster if (!cluster) { this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) diff --git a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts index 5710c089fe..cef37e84e8 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -74,6 +74,7 @@ describe('deploymentDatastoreService', () => { include: { repository: true }, }, }, + orderBy: { createdAt: 'asc' }, }) expect(result).toEqual(deployments) }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts index 36d05e8949..859a299494 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -27,6 +27,7 @@ export class DeploymentDatastoreService { include: { repository: true }, }, }, + orderBy: { createdAt: 'asc' }, }) } diff --git a/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +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 makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts index 80f0a15b2a..bc7be1893d 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -1,9 +1,12 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectExecutionContext } from '../infrastructure/permission/project/project-context.decorator' 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 { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' import { DeploymentController } from './deployment.controller' import { DeploymentService } from './deployment.service' @@ -12,18 +15,11 @@ describe('deploymentController', () => { let controller: DeploymentController let service: DeepMockProxy - beforeEach(async () => { - service = mockDeep() - - module = await Test.createTestingModule({ - controllers: [DeploymentController], - providers: [ - { provide: DeploymentService, useValue: service }, - ], - }).compile() - - controller = module.get(DeploymentController) - }) + const projectCtx: ProjectExecutionContext = { + projectId: '11111111-1111-1111-1111-111111111111', + userId: 'user-uuid-1234', + requestId: 'request-uuid-5678', + } const validCreateDeployment = { name: 'dev', @@ -54,12 +50,17 @@ describe('deploymentController', () => { } satisfies UpdateDeployment beforeEach(async () => { + service = mockDeep() + module = await Test.createTestingModule({ controllers: [DeploymentController], providers: [ { provide: DeploymentService, useValue: service }, ], - }).compile() + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() controller = module.get(DeploymentController) }) @@ -71,7 +72,7 @@ describe('deploymentController', () => { describe('list', () => { it('should call deploymentService.listByProjectId with projectId', async () => { const projectId = '11111111-1111-1111-1111-111111111111' - const expectedResult = [{ id: 'deployment-1' }] + const expectedResult = [makeDeploymentWithRelations({ projectId })] service.listByProjectId.mockResolvedValue(expectedResult) @@ -84,14 +85,14 @@ describe('deploymentController', () => { describe('create', () => { it('should validate body and call deploymentService.createDeployment', async () => { - const expectedResult = { id: 'new-deployment-id' } + const expectedResult = makeDeployment() service.createDeployment.mockResolvedValue(expectedResult) - const result = await controller.create(validCreateDeployment) + const result = await controller.create(validCreateDeployment, projectCtx) expect(service.createDeployment).toHaveBeenCalledWith( - validCreateDeployment.projectId, + projectCtx, validCreateDeployment, ) expect(result).toEqual(expectedResult) @@ -101,13 +102,14 @@ describe('deploymentController', () => { describe('update', () => { it('should validate body and call deploymentService.updateDeployment', async () => { const deploymentId = '55555555-5555-5555-5555-555555555555' - const expectedResult = { id: deploymentId } + const expectedResult = makeDeployment({ id: deploymentId }) service.updateDeployment.mockResolvedValue(expectedResult) - const result = await controller.update(deploymentId, validUpdateDeployment) + const result = await controller.update(deploymentId, validUpdateDeployment, projectCtx) expect(service.updateDeployment).toHaveBeenCalledWith( + projectCtx, deploymentId, validUpdateDeployment, ) @@ -121,9 +123,9 @@ describe('deploymentController', () => { service.deleteDeployment.mockResolvedValue(undefined) - const result = await controller.delete(deploymentId) + const result = await controller.delete(deploymentId, projectCtx) - expect(service.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(service.deleteDeployment).toHaveBeenCalledWith(projectCtx, deploymentId) expect(result).toBeUndefined() }) }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.ts index 9bee0135a2..c95f2f85be 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.controller.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -1,40 +1,55 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { ProjectExecutionContext } from '../infrastructure/permission/project/project-context.decorator' import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Query } from '@nestjs/common' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, UseGuards } from '@nestjs/common' +import { ProjectContext } from '../infrastructure/permission/project/project-context.decorator' +import { ProjectId } from '../infrastructure/permission/project/project-id.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.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 { DeploymentService } from './deployment.service' -// TODO add auth and project perms guard -@Controller('api/v1/deployments') +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) export class DeploymentController { constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} @Get('') - list(@Query('projectId') projectId: string) { + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@ProjectId() projectId: string) { return this.deploymentService.listByProjectId(projectId) } @Post('') + @RequireProjectPermission('ManageDeployments') @HttpCode(HttpStatus.CREATED) create( @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @ProjectContext() projectCtx: ProjectExecutionContext, ) { - const projectId = data.projectId - return this.deploymentService.createDeployment(projectId, data) + return this.deploymentService.createDeployment(projectCtx, data) } - @Put('/:deploymentId') + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') @HttpCode(HttpStatus.OK) update( @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) data: UpdateDeployment, + @ProjectContext() projectCtx: ProjectExecutionContext, ) { - return this.deploymentService.updateDeployment(deploymentId, data) + return this.deploymentService.updateDeployment(projectCtx, deploymentId, data) } - @Delete('/:deploymentId') + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') @HttpCode(HttpStatus.NO_CONTENT) - delete(@Param('deploymentId') deploymentId: string) { - return this.deploymentService.deleteDeployment(deploymentId) + delete( + @Param('deploymentId') deploymentId: string, + @ProjectContext() projectCtx: ProjectExecutionContext, + ) { + return this.deploymentService.deleteDeployment(projectCtx, deploymentId) } } diff --git a/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/apps/server-nestjs/src/modules/deployment/deployment.module.ts index 5dc9a5690f..65ba35fc8f 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.module.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common' import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' import { ProjectModule } from '../project/project.module' import { DeploymentDatastoreService } from './deployment-datastore.service' import { DeploymentController } from './deployment.controller' import { DeploymentService } from './deployment.service' @Module({ - imports: [InfrastructureModule, ProjectModule], + imports: [InfrastructureModule, ProjectModule, LogModule], controllers: [DeploymentController], providers: [ DeploymentDatastoreService, diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts index 639ff1df1c..aa2828768f 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -1,12 +1,17 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectExecutionContext } from '../infrastructure/permission/project/project-context.decorator' +import { InternalServerErrorException } 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 { LogService } from '../log/log.service' +import { makeProjectWithDetails } from '../project/project-testing.utils' import { ProjectService } from '../project/project.service' import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' import { DeploymentService } from './deployment.service' describe('deploymentService', () => { @@ -15,14 +20,19 @@ describe('deploymentService', () => { let datastore: DeepMockProxy let projectService: DeepMockProxy let events: DeepMockProxy + let logService: DeepMockProxy const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' const deploymentId = '22222222-2222-2222-2222-222222222222' - const mockProject = { - id: projectId, - name: 'Test Project', - } + const projectCtx: ProjectExecutionContext = { projectId, userId, requestId } + + const mockProject = makeProjectWithDetails({ id: projectId }) + + const okArgoCDResult = [{ argocd: { status: 'OK', message: 'Up to date', executionTime: 10 } }] + const koArgoCDResult = [{ argocd: { status: 'KO', message: 'Failed', executionTime: 10, error: new Error('sync error') } }] const validCreateDeployment = { name: 'mydeployment', @@ -58,6 +68,7 @@ describe('deploymentService', () => { datastore = mockDeep() projectService = mockDeep() events = mockDeep() + logService = mockDeep() module = await Test.createTestingModule({ providers: [ @@ -65,6 +76,7 @@ describe('deploymentService', () => { { provide: DeploymentDatastoreService, useValue: datastore }, { provide: ProjectService, useValue: projectService }, { provide: EventEmitter2, useValue: events }, + { provide: LogService, useValue: logService }, ], }).compile() @@ -77,7 +89,7 @@ describe('deploymentService', () => { describe('listByProjectId', () => { it('should return deployments by projectId', async () => { - const deployments = [{ id: deploymentId }] + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) const result = await service.listByProjectId(projectId) @@ -88,14 +100,14 @@ describe('deploymentService', () => { }) describe('createDeployment', () => { - it('should create deployment and upsert project', async () => { - const createdDeployment = { id: deploymentId } + it('should create deployment and trigger argocd update', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) datastore.createDeployment.mockResolvedValue(createdDeployment) projectService.get.mockResolvedValue(mockProject) - events.emitAsync.mockResolvedValue([]) + events.emitAsync.mockResolvedValue(okArgoCDResult) - const result = await service.createDeployment(projectId, validCreateDeployment) + const result = await service.createDeployment(projectCtx, validCreateDeployment) expect(datastore.createDeployment).toHaveBeenCalledWith({ name: validCreateDeployment.name, @@ -116,29 +128,47 @@ describe('deploymentService', () => { }) expect(projectService.get).toHaveBeenCalledWith(projectId) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + expect(events.emitAsync).toHaveBeenCalledWith('project.argocd.update', mockProject) + expect(logService.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Create Deployment', + userId, + requestId, + projectId, + })) expect(result).toEqual(createdDeployment) }) + + it('should throw InternalServerErrorException when argocd sync fails', async () => { + datastore.createDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + projectService.get.mockResolvedValue(mockProject) + events.emitAsync.mockResolvedValue(koArgoCDResult) + + await expect(service.createDeployment(projectCtx, validCreateDeployment)) + .rejects.toThrow(InternalServerErrorException) + + expect(logService.addLog).toHaveBeenCalled() + }) }) describe('updateDeployment', () => { - it('should update deployment and upsert project', async () => { - const existingDeployment = { + it('should update deployment and trigger argocd update', async () => { + const existingDeployment = makeDeploymentWithRelations({ id: deploymentId, + projectId, deploymentSources: [ - { id: '55555555-5555-5555-5555-555555555555' }, - { id: '66666666-6666-6666-6666-666666666666' }, + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), ], - } + }) - const updatedDeployment = { id: deploymentId } + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) datastore.getDeploymentById.mockResolvedValue(existingDeployment) datastore.updateDeployment.mockResolvedValue(updatedDeployment) projectService.get.mockResolvedValue(mockProject) - events.emitAsync.mockResolvedValue([]) + events.emitAsync.mockResolvedValue(okArgoCDResult) - const result = await service.updateDeployment(deploymentId, validUpdateDeployment) + const result = await service.updateDeployment(projectCtx, deploymentId, validUpdateDeployment) expect(datastore.updateDeployment).toHaveBeenCalledWith( deploymentId, @@ -153,8 +183,14 @@ describe('deploymentService', () => { }), ) - expect(projectService.get).toHaveBeenCalledWith(validUpdateDeployment.projectId) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + expect(projectService.get).toHaveBeenCalledWith(projectId) + expect(events.emitAsync).toHaveBeenCalledWith('project.argocd.update', mockProject) + expect(logService.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Deployment', + userId, + requestId, + projectId, + })) expect(result).toEqual(updatedDeployment) }) @@ -162,41 +198,54 @@ describe('deploymentService', () => { datastore.getDeploymentById.mockResolvedValue(null) await expect( - service.updateDeployment(deploymentId, validUpdateDeployment), + service.updateDeployment(projectCtx, deploymentId, validUpdateDeployment), ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) expect(datastore.updateDeployment).not.toHaveBeenCalled() }) - }) - describe('deleteDeployment', () => { - it('should delete deployment and upsert project', async () => { - datastore.deleteDeployment.mockResolvedValue({ + it('should throw InternalServerErrorException when argocd sync fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ id: deploymentId, projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) projectService.get.mockResolvedValue(mockProject) - events.emitAsync.mockResolvedValue([]) - - await service.deleteDeployment(deploymentId) + events.emitAsync.mockResolvedValue(koArgoCDResult) - expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) - expect(projectService.get).toHaveBeenCalledWith(projectId) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + await expect(service.updateDeployment(projectCtx, deploymentId, validUpdateDeployment)) + .rejects.toThrow(InternalServerErrorException) }) }) - describe('deleteAllDeploymentsByProjectId', () => { - it('should delete all deployments and upsert project', async () => { - datastore.deleteAllDeploymentsByProjectId.mockResolvedValue(undefined) + describe('deleteDeployment', () => { + it('should delete deployment and trigger argocd update', async () => { projectService.get.mockResolvedValue(mockProject) - events.emitAsync.mockResolvedValue([]) + events.emitAsync.mockResolvedValue(okArgoCDResult) - await service.deleteAllDeploymentsByProjectId(projectId) + await service.deleteDeployment(projectCtx, deploymentId) - expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) expect(projectService.get).toHaveBeenCalledWith(projectId) - expect(events.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + expect(events.emitAsync).toHaveBeenCalledWith('project.argocd.update', mockProject) + expect(logService.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Delete Deployment', + userId, + requestId, + projectId, + })) + }) + + it('should throw InternalServerErrorException when argocd sync fails', async () => { + projectService.get.mockResolvedValue(mockProject) + events.emitAsync.mockResolvedValue(koArgoCDResult) + + await expect(service.deleteDeployment(projectCtx, deploymentId)) + .rejects.toThrow(InternalServerErrorException) }) }) }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.ts index 707c8a27d5..6d097eedba 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -1,22 +1,42 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' -import { Inject, Injectable } from '@nestjs/common' +import type { InputJsonValue } from '@prisma/client/runtime/library' +import type { ProjectExecutionContext } from '../infrastructure/permission/project/project-context.decorator' +import type { RequiredServiceResult } from '../plugin/plugin.utils' +import { Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' +import { LogService } from '../log/log.service' +import { getFailedServices, mergeServiceResults } from '../plugin/plugin.utils' import { ProjectService } from '../project/project.service' import { DeploymentDatastoreService } from './deployment-datastore.service' +type DeploymentAction = 'Create Deployment' + | 'Update Deployment' + | 'Delete Deployment' + +interface UpdateArgoCDProjectOptions { + projectId: string + userId: string + requestId: string + action: DeploymentAction +} + @Injectable() export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + constructor( @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, @Inject(ProjectService) private readonly projectService: ProjectService, @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logService: LogService, ) {} async listByProjectId(projectId: string) { return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) } - async createDeployment(projectId: string, deploymentToCreate: CreateDeployment) { + async createDeployment(projectCtx: ProjectExecutionContext, deploymentToCreate: CreateDeployment) { + const { projectId, userId, requestId } = projectCtx const deployment = await this.deploymentDatastoreService.createDeployment({ name: deploymentToCreate.name, project: { connect: { id: projectId } }, @@ -35,12 +55,17 @@ export class DeploymentService { }, }) - await this.upsertProject(projectId) - // TODO handle result and add logs + await this.updateArgoCDProject({ + projectId, + userId, + requestId, + action: 'Create Deployment', + }) return deployment } - async updateDeployment(deploymentId: string, deploymentToUpdate: UpdateDeployment) { + async updateDeployment(projectCtx: ProjectExecutionContext, deploymentId: string, deploymentToUpdate: UpdateDeployment) { + const { projectId, userId, requestId } = projectCtx const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) @@ -81,26 +106,54 @@ export class DeploymentService { })), }, }) - await this.upsertProject(deploymentToUpdate.projectId) - // TODO handle result and add logs + await this.updateArgoCDProject({ + projectId, + userId, + requestId, + action: 'Update Deployment', + }) return deployment } - async deleteDeployment(deploymentId: string) { - const deployment = await this.deploymentDatastoreService.deleteDeployment(deploymentId) - await this.upsertProject(deployment.projectId) - // TODO handle result and add logs - } - - async deleteAllDeploymentsByProjectId(projectId: string) { - await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) - await this.upsertProject(projectId) - // TODO handle result and add logs + async deleteDeployment(projectCtx: ProjectExecutionContext, deploymentId: string) { + const { projectId, userId, requestId } = projectCtx + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + await this.updateArgoCDProject({ + projectId, + userId, + requestId, + action: 'Delete Deployment', + }) } - private async upsertProject(projectId: string) { + private async updateArgoCDProject(args: UpdateArgoCDProjectOptions) { + const { projectId, userId, requestId, action } = args const projectWithDetails = await this.projectService.get(projectId) - await this.eventEmitter.emitAsync('project.upsert', projectWithDetails) + this.logger.log(`project.argocd.update started (projectId=${projectId})`) + const eventResults: RequiredServiceResult<'argocd'>[] = await this.eventEmitter.emitAsync('project.argocd.update', projectWithDetails) + const results = mergeServiceResults(eventResults) + const failed = getFailedServices(results) + + await this.logService.addLog({ + action, + data: { + args: { + ...projectWithDetails, + }, + failed: failed.length > 0 ? failed : undefined, + results: results as InputJsonValue, + }, + userId, + requestId, + projectId, + }) + + if (failed.length > 0) { + this.logger.error(`project.argocd.update failed (projectId=${projectId})`) + throw new InternalServerErrorException('Synchronization Argo CD Failed') + } + + this.logger.log(`project.argocd.update completed (projectId=${projectId})`) } } diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/project/project-context.decorator.ts b/apps/server-nestjs/src/modules/infrastructure/permission/project/project-context.decorator.ts new file mode 100644 index 0000000000..85d89ee133 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/permission/project/project-context.decorator.ts @@ -0,0 +1,27 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator.js' +import { createParamDecorator } from '@nestjs/common' + +export interface ProjectExecutionContext { + projectId: string + userId: string + requestId: string +} + +export const ProjectContext = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectExecutionContext => { + const request = ctx.switchToHttp().getRequest & UserContext>() + if (!request.params?.projectId) { + throw new Error('Project context is missing from the request') + } + if (!request.userId) { + throw new Error('User context is missing from the request') + } + return { + projectId: request.params.projectId, + userId: request.userId, + requestId: request.id, + } + }, +) diff --git a/apps/server-nestjs/src/modules/infrastructure/permission/project/project-id.decorator.ts b/apps/server-nestjs/src/modules/infrastructure/permission/project/project-id.decorator.ts new file mode 100644 index 0000000000..db204263b1 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/permission/project/project-id.decorator.ts @@ -0,0 +1,13 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export const ProjectId = createParamDecorator( + (_: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest>() + if (!request.params?.projectId) { + throw new Error('Project context is missing from the request') + } + return request.params.projectId + }, +) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..009ad10412 --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { getFailedServices, mergeServiceResults } from './plugin.utils' + +describe('mergeServiceResults', () => { + it('should return empty object when given empty array', () => { + expect(mergeServiceResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergeServiceResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergeServiceResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same service', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergeServiceResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedServices', () => { + it('should return empty array when all services are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedServices(results)).toEqual([]) + }) + + it('should return names of KO services', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedServices(results)).toEqual(['argocd']) + }) + + it('should return all services when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedServices(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedServices({})).toEqual([]) + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/apps/server-nestjs/src/modules/plugin/plugin.utils.ts index 458d4cb54a..76b92a11d9 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.utils.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -10,3 +10,41 @@ export function makeToUrlParams(overrides: Partial = {}): ToU ...overrides, } } +type ServiceName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +type ServiceResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type ServiceResults = Partial> + +export type RequiredServiceResult + = { [K in T]: ServiceResult } & ServiceResults + +export function mergeServiceResults(responses: ServiceResults[]): ServiceResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as ServiceResults) +} + +export function getFailedServices(response: ServiceResults): ServiceName[] { + const entries = Object.entries(response) as [ServiceName, ServiceResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([serviceName]) => serviceName) +} diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.ts index 550baebd20..ff6d4fe915 100644 --- a/apps/server-nestjs/src/modules/project/project-datastore.service.ts +++ b/apps/server-nestjs/src/modules/project/project-datastore.service.ts @@ -6,6 +6,16 @@ const projectSelect = { id: true, name: true, slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, plugins: { select: { pluginName: true, @@ -13,6 +23,26 @@ const projectSelect = { value: true, }, }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, repositories: { select: { id: true, @@ -21,6 +51,20 @@ const projectSelect = { helmValuesFiles: true, deployRevision: true, deployPath: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, }, }, environments: { diff --git a/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server-nestjs/src/prisma/schema/project.prisma b/apps/server-nestjs/src/prisma/schema/project.prisma index 2f34ba6984..6bcc5ce949 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -7,7 +7,7 @@ model Deployment { updatedAt DateTime @updatedAt environmentId String @db.Uuid project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - environment Environment @relation(fields: [environmentId], references: [id]) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) deploymentSources DeploymentSource[] } @@ -21,7 +21,7 @@ model DeploymentSource { targetRevision String @default("") path String @default("") helmValuesFiles String @default("") - repository Repository @relation(fields: [repositoryId], references: [id]) + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) } diff --git a/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/src/prisma/schema/project.prisma b/apps/server/src/prisma/schema/project.prisma index 2f34ba6984..6bcc5ce949 100644 --- a/apps/server/src/prisma/schema/project.prisma +++ b/apps/server/src/prisma/schema/project.prisma @@ -7,7 +7,7 @@ model Deployment { updatedAt DateTime @updatedAt environmentId String @db.Uuid project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - environment Environment @relation(fields: [environmentId], references: [id]) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) deploymentSources DeploymentSource[] } @@ -21,7 +21,7 @@ model DeploymentSource { targetRevision String @default("") path String @default("") helmValuesFiles String @default("") - repository Repository @relation(fields: [repositoryId], references: [id]) + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) } diff --git a/packages/shared/src/schemas/deployment.ts b/packages/shared/src/schemas/deployment.ts index c838718bf9..b1e0f8ee47 100644 --- a/packages/shared/src/schemas/deployment.ts +++ b/packages/shared/src/schemas/deployment.ts @@ -1,13 +1,13 @@ import type Zod from 'zod' import { z } from 'zod' -import { longestEnvironmentName } from '../utils/const.js' +import { longestDeploymentName } from '../utils/const.js' import { AtDatesToStringExtend } from './_utils.js' import { EnvironmentSchema } from './environment.js' import { RepoSchema } from './repository.js' const DeploymentSourceType = z.enum(['git', 'oci']) -const DeploymentSourceSchema = z.object({ +export const DeploymentSourceSchema = z.object({ id: z.string() .uuid(), deploymentId: z.string() @@ -28,7 +28,7 @@ export const DeploymentSchema = z.object({ name: z.string() .regex(/^[a-z0-9]+$/) .min(2) - .max(longestEnvironmentName), + .max(longestDeploymentName), projectId: z.string() .uuid(), environmentId: z.string() diff --git a/packages/shared/src/utils/const.ts b/packages/shared/src/utils/const.ts index 0f546feed4..9c80f33998 100644 --- a/packages/shared/src/utils/const.ts +++ b/packages/shared/src/utils/const.ts @@ -26,6 +26,7 @@ export const projectRoles = [ export type ProjectRoles = typeof projectRoles[number] export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const export const allStatus = [ 'initializing', diff --git a/packages/shared/src/utils/permissions.ts b/packages/shared/src/utils/permissions.ts index 0ea427b718..040fbf55d1 100644 --- a/packages/shared/src/utils/permissions.ts +++ b/packages/shared/src/utils/permissions.ts @@ -59,6 +59,8 @@ export const PROJECT_PERMS = { // project permissions LIST_REPOSITORIES: bit(9n), LIST_MEMBERS: bit(10n), LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), } // Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! @@ -142,6 +144,10 @@ export const ProjectAuthorized = { SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), } as const interface ScopePerm { @@ -209,6 +215,20 @@ export const projectPermsDetails: PermDetails = [{ hint: 'Permet de visualiser tous les dépôts et leurs configurations', }, ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], }] as const export const adminPermsDetails: PermDetails = [{