diff --git a/apps/nginx-strangler/conf.d/routing.conf b/apps/nginx-strangler/conf.d/routing.conf index 1c34f39313..cbcf660691 100644 --- a/apps/nginx-strangler/conf.d/routing.conf +++ b/apps/nginx-strangler/conf.d/routing.conf @@ -67,6 +67,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /api/v1/deployments { + 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 2ed5eac10d..a0dc901c88 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' import { EventEmitterModule } from '@nestjs/event-emitter' import { ScheduleModule } from '@nestjs/schedule' +import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { ProjectModule } from './modules/project/project.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' @@ -15,6 +17,8 @@ import { VersionModule } from './modules/version/version.module' ScheduleModule.forRoot(), SystemSettingsModule, ServiceChainModule, + ProjectModule, + DeploymentModule, VersionModule, ], controllers: [], diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts index 5ccb2f6b10..d3103a2e31 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -27,20 +27,61 @@ export const projectSelect = { select: { id: true, name: true, - clusterId: true, cpu: true, gpu: true, memory: true, autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, }, }, - clusters: { + deployments: { select: { id: true, - label: true, - zone: { + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { select: { - slug: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, }, }, }, diff --git a/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} 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 1f59ccd750..f7234200d0 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,6 +1,5 @@ import type { TestingModule } from '@nestjs/testing' import type { Mocked } from 'vitest' -import type { ProjectWithDetails } from './argocd-datastore.service' import { generateNamespaceName } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,6 +9,7 @@ import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '. import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' import { ArgoCDService } from './argocd.service' function createArgoCDControllerServiceTestingModule() { @@ -73,29 +73,17 @@ describe('argoCDService', () => { }) it('should sync project environments', async () => { - const mockProject = { - id: '123e4567-e89b-12d3-a456-426614174000', + const mockProject = makeProjectWithDetails({ slug: 'project-1', name: 'Project 1', environments: [ - { id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, - { id: '123e4567-e89b-12d3-a456-426614174002', name: 'prod', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, - ], - clusters: [ - { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } }, - ], - repositories: [ - { - id: 'repo-1', - internalRepoName: 'infra-repo', - isInfra: true, - deployRevision: 'HEAD', - deployPath: '.', - helmValuesFiles: '', - }, + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], - } satisfies ProjectWithDetails + deployments: [], + }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) datastore.getAllProjects.mockResolvedValue([mockProject]) @@ -121,10 +109,10 @@ describe('argoCDService', () => { content: stringify({ common: { 'dso/project': 'Project 1', - 'dso/project.id': '123e4567-e89b-12d3-a456-426614174000', + 'dso/project.id': mockProject.id, 'dso/project.slug': 'project-1', 'dso/environment': 'dev', - 'dso/environment.id': '123e4567-e89b-12d3-a456-426614174001', + 'dso/environment.id': mockProject.environments[0].id, }, argocd: { cluster: 'in-cluster', @@ -156,7 +144,7 @@ describe('argoCDService', () => { memory: '1Gi', }, sourceRepositories: [ - 'https://gitlab.internal/group/**', + 'https://gitlab.internal/group/project-1/**', 'repo3', 'repo2', ], @@ -168,7 +156,9 @@ describe('argoCDService', () => { vault: { secret: 'value' }, repositories: [ { - repoURL: 'https://gitlab.internal/infra-repo', + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, targetRevision: 'HEAD', path: '.', valueFiles: [], @@ -188,10 +178,10 @@ describe('argoCDService', () => { content: stringify({ common: { 'dso/project': 'Project 1', - 'dso/project.id': '123e4567-e89b-12d3-a456-426614174000', + 'dso/project.id': mockProject.id, 'dso/project.slug': 'project-1', 'dso/environment': 'prod', - 'dso/environment.id': '123e4567-e89b-12d3-a456-426614174002', + 'dso/environment.id': mockProject.environments[1].id, }, argocd: { cluster: 'in-cluster', @@ -223,7 +213,7 @@ describe('argoCDService', () => { memory: '1Gi', }, sourceRepositories: [ - 'https://gitlab.internal/group/**', + 'https://gitlab.internal/group/project-1/**', 'repo3', 'repo2', ], @@ -235,7 +225,9 @@ describe('argoCDService', () => { vault: { secret: 'value' }, repositories: [ { - repoURL: 'https://gitlab.internal/infra-repo', + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', targetRevision: 'HEAD', path: '.', valueFiles: [], @@ -262,28 +254,15 @@ describe('argoCDService', () => { }) it('should delete values file when an environment is removed', async () => { - const mockProject = { - id: '123e4567-e89b-12d3-a456-426614174000', + const mockProject = makeProjectWithDetails({ slug: 'project-1', name: 'Project 1', environments: [ - { id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, - ], - clusters: [ - { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } }, + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), ], - repositories: [ - { - id: 'repo-1', - internalRepoName: 'infra-repo', - isInfra: true, - deployRevision: 'HEAD', - deployPath: '.', - helmValuesFiles: '', - }, - ], - plugins: [], - } satisfies ProjectWithDetails + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) datastore.getAllProjects.mockResolvedValue([mockProject]) @@ -325,28 +304,15 @@ describe('argoCDService', () => { }) it('should not commit when there is no diff', async () => { - const mockProject = { - id: '123e4567-e89b-12d3-a456-426614174000', + const mockProject = makeProjectWithDetails({ slug: 'project-1', name: 'Project 1', environments: [ - { id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), ], - clusters: [ - { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } }, - ], - repositories: [ - { - id: 'repo-1', - internalRepoName: 'infra-repo', - isInfra: true, - deployRevision: 'HEAD', - deployPath: '.', - helmValuesFiles: '', - }, - ], - plugins: [], - } satisfies ProjectWithDetails + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) datastore.getAllProjects.mockResolvedValue([mockProject]) @@ -362,4 +328,140 @@ describe('argoCDService', () => { expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + 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({ secret: 'value' }) + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index c3805363db..05d8b94d17 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -136,13 +136,18 @@ export class ArgoCDService { infraProject, zoneSlug, ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( project, infraProject, zoneSlug, ) return [ - ...environmentActions, + ...(deploymentActions.length ? deploymentActions : environmentActions), ...purgeEnvironmentActions, ] satisfies CommitAction[] } @@ -154,15 +159,14 @@ export class ArgoCDService { ): Promise { const neededFiles = new Set() const clusterLabelsInZone = new Set( - project.clusters - .filter(c => c.zone.slug === zoneSlug) - .map(c => c.label), + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), ) project.environments.forEach((env) => { - const cluster = project.clusters.find(c => c.id === env.clusterId) - if (cluster?.zone.slug !== zoneSlug) return - neededFiles.add(formatEnvironmentValuesFilePath(project, cluster, env)) + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) }) const existingFiles = await this.gitlab.listFiles(infraProject, { @@ -198,13 +202,10 @@ export class ArgoCDService { this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) const actions = await Promise.all( environments - .filter((env) => { - const cluster = project.clusters.find(c => c.id === env.clusterId) - return cluster?.zone.slug === zoneSlug - }) + .filter(env => env.cluster?.zone.slug === zoneSlug) .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), ) - const filteredActions = actions.filter(a => !!a) as CommitAction[] + const filteredActions: CommitAction[] = actions.filter(a => !!a) this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) return filteredActions } @@ -221,7 +222,7 @@ export class ArgoCDService { 'environment.name': environment.name, }) const vaultValues = await this.vault.readProjectValues(project.id) ?? {} - const cluster = project.clusters.find(c => c.id === environment.clusterId) + const cluster = environment.cluster if (!cluster) { this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) throw new Error(`Cluster not found for environment ${environment.id}`) @@ -233,23 +234,95 @@ export class ArgoCDService { const repo = project.repositories.find(r => r.isInfra) if (!repo) { this.logger.warn(`Infrastructure repository not found for project ${project.slug} (projectId=${project.id})`) - throw new Error(`Infra repository not found for project ${project.id}`) + return null + } + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.config.argocdExtraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.config.argoNamespace, + envChartVersion: this.config.dsoEnvChartVersion, + nsChartVersion: this.config.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.vault.readProjectValues(project.id) ?? {} + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) } - const repoUrl = await this.gitlab.getOrCreateInfraGroupRepoPublicUrl(repo.internalRepoName) + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` const values = formatValues({ project, environment, cluster, - gitlabPublicGroupUrl: await this.gitlab.getOrCreateProjectGroupPublicUrl(), + gitlabPublicProjectUrl, argocdExtraRepositories: this.config.argocdExtraRepositories, infraProject, valueFilePath, - repoUrl, vaultValues, argoNamespace: this.config.argoNamespace, envChartVersion: this.config.dsoEnvChartVersion, nsChartVersion: this.config.dsoNsChartVersion, + deployments, }) return this.gitlab.generateCreateOrUpdateAction( @@ -306,6 +379,8 @@ interface ValuesSchema { autosync: boolean vault: Record repositories: { + name: string + id: string repoURL: string targetRevision: string path: string @@ -349,7 +424,7 @@ function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { l function getDistinctZones(project: ProjectWithDetails) { const zones = new Set() - project.clusters.forEach(c => zones.add(c.zone.slug)) + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) return [...zones] } @@ -360,7 +435,7 @@ function splitExtraRepositories(extraRepositories: string | undefined): string[] function formatRepositoriesValues( repositories: ProjectWithDetails['repositories'], - repoUrl: string, + gitlabPublicProjectUrl: string, envName: string, ) { return repositories @@ -368,7 +443,9 @@ function formatRepositoriesValues( .map((repository) => { const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) return { - repoURL: repoUrl, + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, targetRevision: repository.deployRevision || 'HEAD', path: repository.deployPath || '.', valueFiles, @@ -376,6 +453,27 @@ function formatRepositoriesValues( }) } +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + function formatEnvironmentValues( project: ProjectWithDetails, infraProject: SimpleProjectSchema, @@ -402,13 +500,13 @@ function formatEnvironmentValues( } interface FormatSourceRepositoriesValuesOptions { - gitlabPublicGroupUrl: string + gitlabPublicProjectUrl: string argocdExtraRepositories?: string projectPlugins?: ProjectWithDetails['plugins'] } function formatSourceRepositoriesValues( - { gitlabPublicGroupUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, ): string[] { let projectExtraRepositories = '' if (projectPlugins) { @@ -417,7 +515,7 @@ function formatSourceRepositoriesValues( } return [ - `${gitlabPublicGroupUrl}/**`, + `${gitlabPublicProjectUrl}/**`, ...splitExtraRepositories(argocdExtraRepositories), ...splitExtraRepositories(projectExtraRepositories), ] @@ -459,31 +557,31 @@ function formatArgoCDValues(options: FormatArgoCDValuesOptions) { interface FormatValuesOptions { project: ProjectWithDetails environment: ProjectWithDetails['environments'][number] - cluster: ProjectWithDetails['clusters'][number] - gitlabPublicGroupUrl: string + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string argocdExtraRepositories?: string vaultValues: Record infraProject: SimpleProjectSchema valueFilePath: string - repoUrl: string argoNamespace: string envChartVersion: string nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] } function formatValues({ project, environment, cluster, - gitlabPublicGroupUrl, + gitlabPublicProjectUrl, argocdExtraRepositories, vaultValues, infraProject, valueFilePath, - repoUrl, argoNamespace, envChartVersion, nsChartVersion, + deployments, }: FormatValuesOptions) { return { common: formatCommon({ project, environment }), @@ -507,7 +605,7 @@ function formatValues({ memory: `${environment.memory}Gi`, }, sourceRepositories: formatSourceRepositoriesValues({ - gitlabPublicGroupUrl, + gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins: project.plugins, }), @@ -517,11 +615,13 @@ function formatValues({ }, autosync: environment.autosync, vault: vaultValues, - repositories: formatRepositoriesValues( - project.repositories, - repoUrl, - environment.name, - ), + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), }, features: { fineGrainedRoles: { 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 new file mode 100644 index 0000000000..5710c089fe --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,154 @@ +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 { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments as any) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated as any) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted as any) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..36d05e8949 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,55 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..ea2570e298 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,130 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +// Global mock for DeploymentService +const mockDeploymentService = { + listByProjectId: vi.fn(), + createDeployment: vi.fn(), + updateDeployment: vi.fn(), + deleteDeployment: vi.fn(), +} + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + + const validCreateDeployment = { + name: 'dev', + projectId: '11111111-1111-1111-1111-111111111111', + environmentId: '22222222-2222-2222-2222-222222222222', + cpu: 1, + gpu: 0, + memory: 512, + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + vi.clearAllMocks() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { + provide: DeploymentService, + useValue: mockDeploymentService, + }, + ], + }).compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const projectId = '11111111-1111-1111-1111-111111111111' + const expectedResult = [{ id: 'deployment-1' }] + + mockDeploymentService.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(projectId) + + expect(mockDeploymentService.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = { id: 'new-deployment-id' } + + mockDeploymentService.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment) + + expect(mockDeploymentService.createDeployment).toHaveBeenCalledWith( + validCreateDeployment.projectId, + validCreateDeployment, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = { id: deploymentId } + + mockDeploymentService.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment) + + expect(mockDeploymentService.updateDeployment).toHaveBeenCalledWith( + deploymentId, + validUpdateDeployment, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + mockDeploymentService.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId) + + expect(mockDeploymentService.deleteDeployment).toHaveBeenCalledWith(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 new file mode 100644 index 0000000000..3dd7e33422 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,40 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, Inject, Param, Post, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +// TODO add auth and project perms guard +@Controller('api/v1/deployments') +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + list(@Query('projectId') projectId: string) { + return this.deploymentService.listByProjectId(projectId) + } + + @Post('') + @HttpCode(201) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + ) { + const projectId = data.projectId + return this.deploymentService.createDeployment(projectId, data) + } + + @Put('/:deploymentId') + @HttpCode(200) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + ) { + return this.deploymentService.updateDeployment(deploymentId, data) + } + + @Delete('/:deploymentId') + @HttpCode(204) + delete(@Param('deploymentId') deploymentId: string) { + return this.deploymentService.deleteDeployment(deploymentId) + } +} diff --git a/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..5dc9a5690f --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.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], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..672f0590bf --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,221 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ProjectService } from '../project/project.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentService } from './deployment.service' + +const mockDeploymentDatastoreService = { + getDeploymentsByProjectId: vi.fn(), + createDeployment: vi.fn(), + getDeploymentById: vi.fn(), + updateDeployment: vi.fn(), + deleteDeployment: vi.fn(), + deleteAllDeploymentsByProjectId: vi.fn(), +} + +const mockProjectService = { + getProjectWithDetails: vi.fn(), +} + +const mockEventEmitter = { + emitAsync: vi.fn(), +} + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + + const projectId = '11111111-1111-1111-1111-111111111111' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const mockProject = { + id: projectId, + name: 'Test Project', + } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + vi.clearAllMocks() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { + provide: DeploymentDatastoreService, + useValue: mockDeploymentDatastoreService, + }, + { + provide: ProjectService, + useValue: mockProjectService, + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [{ id: deploymentId }] + mockDeploymentDatastoreService.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(mockDeploymentDatastoreService.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and upsert project', async () => { + const createdDeployment = { id: deploymentId } + + mockDeploymentDatastoreService.createDeployment.mockResolvedValue(createdDeployment) + mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockEventEmitter.emitAsync.mockResolvedValue([]) + + const result = await service.createDeployment(projectId, validCreateDeployment) + + expect(mockDeploymentDatastoreService.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and upsert project', async () => { + const existingDeployment = { + id: deploymentId, + deploymentSources: [ + { id: '55555555-5555-5555-5555-555555555555' }, + { id: '66666666-6666-6666-6666-666666666666' }, + ], + } + + const updatedDeployment = { id: deploymentId } + + mockDeploymentDatastoreService.getDeploymentById.mockResolvedValue(existingDeployment) + mockDeploymentDatastoreService.updateDeployment.mockResolvedValue(updatedDeployment) + mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockEventEmitter.emitAsync.mockResolvedValue([]) + + const result = await service.updateDeployment(deploymentId, validUpdateDeployment) + + expect(mockDeploymentDatastoreService.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + mockDeploymentDatastoreService.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(deploymentId, validUpdateDeployment), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(mockDeploymentDatastoreService.updateDeployment).not.toHaveBeenCalled() + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and upsert project', async () => { + mockDeploymentDatastoreService.deleteDeployment.mockResolvedValue({ + id: deploymentId, + projectId, + }) + mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockEventEmitter.emitAsync.mockResolvedValue([]) + + await service.deleteDeployment(deploymentId) + + expect(mockDeploymentDatastoreService.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + mockDeploymentDatastoreService.deleteAllDeploymentsByProjectId.mockResolvedValue(undefined) + mockProjectService.getProjectWithDetails.mockResolvedValue(mockProject) + mockEventEmitter.emitAsync.mockResolvedValue([]) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(mockDeploymentDatastoreService.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(mockProjectService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', mockProject) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..8eb4e94e01 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,106 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { ProjectService } from '../project/project.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(ProjectService) private readonly projectService: ProjectService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + await this.upsertProject(projectId) + // TODO handle result and add logs + return deployment + } + + async updateDeployment(deploymentId: string, deploymentToUpdate: UpdateDeployment) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + await this.upsertProject(deploymentToUpdate.projectId) + // TODO handle result and add logs + 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 + } + + private async upsertProject(projectId: string) { + const projectWithDetails = await this.projectService.getProjectWithDetails(projectId) + + await this.eventEmitter.emitAsync('project.upsert', projectWithDetails) + } +} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index 98025d9ac7..28755c798f 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -4,8 +4,8 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' -import { VaultError } from '../vault/vault-http-client.service.js' -import { makeVaultSecret } from '../vault/vault-testing.utils.js' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' import { makeProjectWithDetails } from './nexus-testing.utils' diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index b261547daa..e24fb68a4e 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -11,7 +11,7 @@ import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' -import { VaultError } from '../vault/vault-http-client.service.js' +import { VaultError } from '../vault/vault-http-client.service' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' import { diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts new file mode 100644 index 0000000000..bc10155604 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-datastore.service.spec.ts @@ -0,0 +1,88 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectDatastoreService } from './project-datastore.service' + +const mockPrismaService = { + project: { + findUnique: vi.fn(), + }, +} + +describe('projectDatastoreService', () => { + let module: TestingModule + let service: ProjectDatastoreService + + const projectId = '11111111-1111-1111-1111-111111111111' + + beforeEach(async () => { + vi.clearAllMocks() + + module = await Test.createTestingModule({ + providers: [ + ProjectDatastoreService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile() + + service = module.get(ProjectDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getProjectWithDetails', () => { + it('should call prisma.project.findUnique with correct project id and selection', async () => { + const mockProject = { + id: projectId, + name: 'Test Project', + slug: 'test-project', + plugins: [], + repositories: [], + environments: [], + deployments: [], + } + + mockPrismaService.project.findUnique.mockResolvedValue(mockProject) + + const result = await service.getProjectWithDetails(projectId) + + expect(mockPrismaService.project.findUnique).toHaveBeenCalledTimes(1) + expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + select: expect.objectContaining({ + id: true, + name: true, + slug: true, + plugins: expect.any(Object), + repositories: expect.any(Object), + environments: expect.any(Object), + deployments: expect.any(Object), + }), + }), + ) + + expect(result).toEqual(mockProject) + }) + + it('should return null if project is not found', async () => { + mockPrismaService.project.findUnique.mockResolvedValue(null) + + const result = await service.getProjectWithDetails(projectId) + + expect(mockPrismaService.project.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + }), + ) + + expect(result).toBeNull() + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/project/project-datastore.service.ts b/apps/server-nestjs/src/modules/project/project-datastore.service.ts new file mode 100644 index 0000000000..550baebd20 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-datastore.service.ts @@ -0,0 +1,98 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +@Injectable() +export class ProjectDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectWithDetails(projectId: string) { + return this.prisma.project.findUnique({ where: { id: projectId }, select: projectSelect }) + } +} diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..055105ab3a --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { ProjectDatastoreService } from './project-datastore.service' +import { ProjectService } from './project.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [], + providers: [ + ProjectService, + ProjectDatastoreService, + ], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..a6202fe039 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,69 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ProjectDatastoreService } from './project-datastore.service' +import { ProjectService } from './project.service' + +const mockProjectDatastoreService = { + getProjectWithDetails: vi.fn(), +} + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + + const projectId = '11111111-1111-1111-1111-111111111111' + + beforeEach(async () => { + vi.clearAllMocks() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { + provide: ProjectDatastoreService, + useValue: mockProjectDatastoreService, + }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getProjectWithDetails', () => { + it('should return project details when project exists', async () => { + const mockProject = { + id: projectId, + name: 'Test Project', + slug: 'test-project', + plugins: [], + repositories: [], + environments: [], + deployments: [], + } + + mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(mockProject) + + const result = await service.getProjectWithDetails(projectId) + + expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) + expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + expect(result).toEqual(mockProject) + }) + + it('should throw an error when project does not exist', async () => { + mockProjectDatastoreService.getProjectWithDetails.mockResolvedValue(null) + + await expect(service.getProjectWithDetails(projectId)).rejects.toThrow( + `Project with id ${projectId} not found`, + ) + + expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledTimes(1) + expect(mockProjectDatastoreService.getProjectWithDetails).toHaveBeenCalledWith(projectId) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..965b95960b --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,15 @@ +import { Inject, Injectable } from '@nestjs/common' +import { ProjectDatastoreService } from './project-datastore.service' + +@Injectable() +export class ProjectService { + constructor( + @Inject(ProjectDatastoreService) private readonly deploymentDatastoreService: ProjectDatastoreService, + ) {} + + async getProjectWithDetails(projectId: string) { + const projectWithDetails = await this.deploymentDatastoreService.getProjectWithDetails(projectId) + if (!projectWithDetails) throw new Error(`Project with id ${projectId} not found`) + return projectWithDetails + } +} diff --git a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts index ddb9a48c8f..a61a7768a2 100644 --- a/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -1,5 +1,5 @@ import type { ProjectWithDetails } from './registry-datastore.service' -import type { RegistryResponse } from './registry-http-client.service.js' +import type { RegistryResponse } from './registry-http-client.service' import { faker } from '@faker-js/faker' export function makeOkResponse(data: T): RegistryResponse { diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 6e5210a4b6..0ea8a6ec9b 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -4,10 +4,10 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' -import { makeVaultSecret } from '../vault/vault-testing.utils.js' +import { makeVaultSecret } from '../vault/vault-testing.utils' import { projectRobotName, RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' -import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils.js' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' import { REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index 2fca977eca..bd9e2f9946 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -17,7 +17,7 @@ import { trace } from '@opentelemetry/api' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' -import { VaultError } from '../vault/vault-http-client.service.js' +import { VaultError } from '../vault/vault-http-client.service' import { projectRobotName, RegistryClientService, roAccess, roRobotName, rwAccess, rwRobotName } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index 7a80bae487..c1eb6e1ac6 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,4 +1,4 @@ -import type { ProjectWithDetails } from './registry-datastore.service.js' +import type { ProjectWithDetails } from './registry-datastore.service' import { removeTrailingSlash } from '@cpn-console/shared' const protocolPrefixRegex = /^https?:\/\//u diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts index d3836db25e..9644dc97f0 100644 --- a/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -1,7 +1,7 @@ import type { SystemSetting } from '@cpn-console/shared' import { SystemSettingSchema } from '@cpn-console/shared' import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' -import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe.js' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { SystemSettingsService } from './system-settings.service' @Controller('api/v1/system/settings') diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts index e674e23584..ae9ec6e161 100644 --- a/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -5,7 +5,7 @@ import { faker } from '@faker-js/faker' 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.js' +import { PrismaService } from '../infrastructure/database/prisma.service' import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' import { SystemSettingsService } from './system-settings.service' diff --git a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts index d3bf1da198..cd9f435435 100644 --- a/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -1,4 +1,4 @@ -import type { VaultSecret } from './vault-client.service.js' +import type { VaultSecret } from './vault-client.service' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' import { faker } from '@faker-js/faker' diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index 86f94f1139..96817c3a07 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' -import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils.js' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ diff --git a/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("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 d45ccf451e..2f34ba6984 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -1,36 +1,65 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id]) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id]) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + model Environment { - id String @id @default(uuid()) @db.Uuid - name String @db.VarChar(11) - projectId String @db.Uuid - memory Float @db.Real - cpu Float @db.Real - gpu Float @db.Real - autosync Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - clusterId String @db.Uuid - stageId String @db.Uuid - cluster Cluster @relation(fields: [clusterId], references: [id]) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - stage Stage @relation(fields: [stageId], references: [id]) + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] @@unique([projectId, name]) } model Repository { - id String @id @default(uuid()) @db.Uuid - projectId String @db.Uuid - internalRepoName String - externalRepoUrl String @default("") - externalUserName String @default("") - isInfra Boolean @default(false) - isPrivate Boolean @default(false) - deployRevision String @default("") - deployPath String @default("") - helmValuesFiles String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] } model ProjectClusterHistory { @@ -72,32 +101,33 @@ model ProjectRole { } model Project { - id String @id @unique @default(uuid()) @db.Uuid - name String - description String @default("") - status ProjectStatus @default(initializing) - locked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - everyonePerms BigInt @default(896) - ownerId String @db.Uuid - environments Environment[] - logs Log[] - owner User @relation(fields: [ownerId], references: [id]) - members ProjectMembers[] - plugins ProjectPlugin[] - roles ProjectRole[] - repositories Repository[] - clusters Cluster[] @relation("ClusterToProject") - slug String @unique - limitless Boolean @default(true) - hprodCpu Float @db.Real - hprodGpu Float @db.Real - hprodMemory Float @db.Real - prodCpu Float @db.Real - prodGpu Float @db.Real - prodMemory Float @db.Real + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real lastSuccessProvisionningVersion String? + deployments Deployment[] } enum ProjectStatus { @@ -107,3 +137,8 @@ enum ProjectStatus { archived warning } + +enum DeploymentSourceType { + git + oci +} diff --git a/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("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 d45ccf451e..2f34ba6984 100644 --- a/apps/server/src/prisma/schema/project.prisma +++ b/apps/server/src/prisma/schema/project.prisma @@ -1,36 +1,65 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id]) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id]) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + model Environment { - id String @id @default(uuid()) @db.Uuid - name String @db.VarChar(11) - projectId String @db.Uuid - memory Float @db.Real - cpu Float @db.Real - gpu Float @db.Real - autosync Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - clusterId String @db.Uuid - stageId String @db.Uuid - cluster Cluster @relation(fields: [clusterId], references: [id]) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - stage Stage @relation(fields: [stageId], references: [id]) + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] @@unique([projectId, name]) } model Repository { - id String @id @default(uuid()) @db.Uuid - projectId String @db.Uuid - internalRepoName String - externalRepoUrl String @default("") - externalUserName String @default("") - isInfra Boolean @default(false) - isPrivate Boolean @default(false) - deployRevision String @default("") - deployPath String @default("") - helmValuesFiles String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] } model ProjectClusterHistory { @@ -72,32 +101,33 @@ model ProjectRole { } model Project { - id String @id @unique @default(uuid()) @db.Uuid - name String - description String @default("") - status ProjectStatus @default(initializing) - locked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - everyonePerms BigInt @default(896) - ownerId String @db.Uuid - environments Environment[] - logs Log[] - owner User @relation(fields: [ownerId], references: [id]) - members ProjectMembers[] - plugins ProjectPlugin[] - roles ProjectRole[] - repositories Repository[] - clusters Cluster[] @relation("ClusterToProject") - slug String @unique - limitless Boolean @default(true) - hprodCpu Float @db.Real - hprodGpu Float @db.Real - hprodMemory Float @db.Real - prodCpu Float @db.Real - prodGpu Float @db.Real - prodMemory Float @db.Real + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real lastSuccessProvisionningVersion String? + deployments Deployment[] } enum ProjectStatus { @@ -107,3 +137,8 @@ enum ProjectStatus { archived warning } + +enum DeploymentSourceType { + git + oci +} diff --git a/packages/shared/src/schemas/deployment.ts b/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..c838718bf9 --- /dev/null +++ b/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } 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({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index c8c4ac6881..236c4124b2 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,5 +1,6 @@ export * from './cluster.js' export * from './config.js' +export * from './deployment.js' export * from './environment.js' export * from './log.js' export * from './project.js' diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index 8b4da88ecc..e7bc172877 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -2300,4 +2300,6 @@ export const data = { ], ], ], + deployment: [], + deploymentSource: [], }