diff --git a/apps/client/src/components/DeleteForm.vue b/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..0a40d21af1 --- /dev/null +++ b/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/client/src/components/DeploymentCard.vue b/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..ae7577af88 --- /dev/null +++ b/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + + + + + + {{ deployment.name }} + + + + {{ deployment.deploymentSources.length }} + + dépôts + + + dépôt + + + + + + {{ source.repository.internalRepoName }} + + {{ source.targetRevision || 'HEAD' }} + + + + + + + diff --git a/apps/client/src/components/DeploymentModal.vue b/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..e305863c25 --- /dev/null +++ b/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,161 @@ + + + + + + + + Modifier le déploiement + + + Ajouter un déploiement au projet + + + + + + + + + Environnement cible + + + Un déploiement est lié à exactement 1 environnement + + + + + Dépôts à inclure + + + + + + + + + + diff --git a/apps/client/src/components/DeploymentRepoOption.vue b/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..4e199d6de5 --- /dev/null +++ b/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/apps/client/src/components/DeploymentRepoSelect.vue b/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + + + + ) => updateDepot(index, value)" + @delete="removeDepot(index)" + /> + + + + + + diff --git a/apps/client/src/components/DeploymentResources.vue b/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..d21ba4615a --- /dev/null +++ b/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,97 @@ + + + + + + + + Déploiements + + Associez un environnement à un ou plusieurs dépôts pour configurer un déploiment. + + openModal()" + /> + + + openModal(deployment)" + /> + + + + diff --git a/apps/client/src/components/ProjectResources.vue b/apps/client/src/components/ProjectResources.vue index 7b82bdfd4f..7ad61cb8fc 100644 --- a/apps/client/src/components/ProjectResources.vue +++ b/apps/client/src/components/ProjectResources.vue @@ -1,5 +1,5 @@ + diff --git a/apps/client/src/main.css b/apps/client/src/main.css index f9098d4dee..58f1adca7f 100644 --- a/apps/client/src/main.css +++ b/apps/client/src/main.css @@ -42,6 +42,7 @@ body, .fr-fieldset { margin: 1rem 0 0; align-items: flex-start; + min-width: 0; } .fr-checkbox-group { @@ -72,6 +73,10 @@ body, background-color: var(--background-alt-grey); } +.fr-alert pre { + white-space: normal; +} + .log-panel, .log-btn { background-color: var(--background-default-grey); } diff --git a/apps/client/src/utils/func.ts b/apps/client/src/utils/func.ts index e66bb7b2d7..71f1a61b5e 100644 --- a/apps/client/src/utils/func.ts +++ b/apps/client/src/utils/func.ts @@ -1,3 +1,4 @@ +import type { Ref } from 'vue' import { useSnackbarStore } from '@/stores/snackbar.js' const LOCALE = navigator.language.slice(0, 2) @@ -85,3 +86,18 @@ export function localeParseFloat(s: string): number { // Now it can be parsed return Number.parseFloat(delocalizedInput) } + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/apps/client/src/utils/project-utils.ts b/apps/client/src/utils/project-utils.ts index 7aa201b8ad..cc20822824 100644 --- a/apps/client/src/utils/project-utils.ts +++ b/apps/client/src/utils/project-utils.ts @@ -1,6 +1,8 @@ import type { + CreateDeploymentBody, CreateEnvironmentBody, CreateRepositoryBody, + Deployment, Environment, GetLogsQuery, PermissionTarget, @@ -13,6 +15,7 @@ import type { Repo, RepositoryParams, Role, + UpdateDeploymentBody, UpdateEnvironmentBody, UpdateRepositoryBody, User, @@ -35,6 +38,7 @@ import { getRandomId } from './func.js' export type ProjectOperations = 'create' | 'delete' + | 'deploymentManagement' | 'envManagement' | 'repoManagement' | 'teamManagement' @@ -80,6 +84,7 @@ export class Project implements ProjectV2 { myPerms: bigint repositories: Ref environments: Ref + deployments: Ref services: ProjectService[] = [] lastSuccessProvisionningVersion: string | null needReplay: Ref @@ -112,6 +117,7 @@ export class Project implements ProjectV2 { this.environments = ref([]) this.repositories = ref([]) this.needReplay = ref(false) + this.deployments = ref([]) } private removeOperation(operationName: ProjectOperations) { @@ -229,6 +235,40 @@ export class Project implements ProjectV2 { }, } + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ body: { ...deploymentData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Deployments.list() + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ body: deployment, params: { deploymentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Deployments.list() + return this.deployments + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ params: { deploymentId } }) + .then((response: any) => extractData(response, 204)) + await this.Deployments.list() + return this.deployments + } finally { callback() } + }, + } + Environments = { list: async () => { this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 05d8b94d17..131a85afd8 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -221,7 +221,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}`) @@ -231,11 +231,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({ @@ -298,7 +293,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/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 2fc7235402..9856763e9f 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -189,12 +189,12 @@ export class GitlabClientService { async getOrCreateProjectGroupPublicUrl(): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return `${this.config.gitlabUrl}/${projectGroup.full_path}` + return new URL(projectGroup.full_path, this.config.gitlabUrl).toString() } async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return `${this.config.gitlabUrl}/${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git` + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.gitlabUrl).toString() } async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts index ec928090e0..8cd83fa40a 100644 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts @@ -74,6 +74,7 @@ export class ConfigurationService { vaultInternalUrl = process.env.VAULT_INTERNAL_URL vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso' + deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' // registry (harbor) harborUrl = process.env.HARBOR_URL 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/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 965b95960b..f4abcea520 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -4,11 +4,11 @@ import { ProjectDatastoreService } from './project-datastore.service' @Injectable() export class ProjectService { constructor( - @Inject(ProjectDatastoreService) private readonly deploymentDatastoreService: ProjectDatastoreService, + @Inject(ProjectDatastoreService) private readonly projectDatastoreService: ProjectDatastoreService, ) {} async getProjectWithDetails(projectId: string) { - const projectWithDetails = await this.deploymentDatastoreService.getProjectWithDetails(projectId) + const projectWithDetails = await this.projectDatastoreService.getProjectWithDetails(projectId) if (!projectWithDetails) throw new Error(`Project with id ${projectId} not found`) return projectWithDetails } diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index 6857d2f84b..4b114ce54e 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -23,7 +23,7 @@ const server = setupServer( }), ) -function createVaultServiceTestingModule() { +function createVaultServiceTestingModule(config: Partial = {}) { return Test.createTestingModule({ providers: [ VaultClientService, @@ -35,7 +35,10 @@ function createVaultServiceTestingModule() { vaultUrl, vaultInternalUrl: vaultUrl, vaultKvName: 'kv', + projectRootDir: 'forge', + deployVaultConnectionInNs: false, getInternalOrPublicVaultUrl: () => vaultUrl, + ...config, } satisfies Partial, }, ], @@ -54,20 +57,44 @@ describe('vault', () => { afterAll(() => server.close()) describe('getProjectValues', () => { - it('should get project values', async () => { + it('should get project values without AppRole credentials if role does not exist', async () => { + server.use( + http.get(`${vaultUrl}/v1/auth/approle/role/:roleName/role-id`, () => { + return HttpResponse.json({}, { status: 404 }) + }), + ) + const result = await service.readProjectValues('project-id') - expect(result).toEqual({ secret: 'value' }) + expect(result).toEqual({ + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'none', + secretId: 'none', + }) }) - it('should return empty object if undefined', async () => { + it('should get project values with AppRole credentials', async () => { server.use( - http.get(`${vaultUrl}/v1/kv/data/:path`, () => { - return HttpResponse.json({}, { status: 404 }) + http.get(`${vaultUrl}/v1/auth/approle/role/:roleName/role-id`, () => { + return HttpResponse.json({ data: { role_id: 'role-id' } }) + }), + http.post(`${vaultUrl}/v1/auth/approle/role/:roleName/secret-id`, () => { + return HttpResponse.json({ data: { secret_id: 'secret-id' } }) }), ) + const module = await createVaultServiceTestingModule({ deployVaultConnectionInNs: true }).compile() + service = module.get(VaultClientService) + const result = await service.readProjectValues('project-id') - expect(result).toEqual(undefined) + expect(result).toEqual({ + projectsRootDir: 'forge', + url: vaultUrl, + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }) }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index 2ea913b61d..2032d7a20f 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -162,15 +162,15 @@ export class VaultClientService { } @StartActiveSpan() - async readProjectValues(projectId: string): Promise | undefined> { - const path = generateProjectPath(this.config.projectRootDir, projectId) - this.logger.debug(`Reading Vault project values (projectId=${projectId}, path=${path})`) - this.logger.verbose(`Reading Vault project values for projectId=${projectId}`) - const secret = await this.read>(path).catch((error) => { - if (error instanceof VaultError && error.kind === 'NotFound') return null - throw error - }) - return secret?.data + async readProjectValues(projectSlug: string): Promise | undefined> { + this.logger.verbose(`Reading Vault project values for projectSlug=${projectSlug}`) + return { + projectsRootDir: this.config.projectRootDir, + url: this.config.deployVaultConnectionInNs ? this.config.vaultUrl : '', + coreKvName: this.config.vaultKvName, + roleId: (await this.getAuthApproleRoleRoleId(projectSlug).catch(() => undefined)) ?? 'none', + secretId: (await this.createAuthApproleRoleSecretId(projectSlug).catch(() => undefined)) ?? 'none', + } } @StartActiveSpan() 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/api-client.ts b/packages/shared/src/api-client.ts index 6c877a08de..96532b6a4d 100644 --- a/packages/shared/src/api-client.ts +++ b/packages/shared/src/api-client.ts @@ -11,6 +11,7 @@ export async function getContract() { AdminRoles: (await import('./contracts/index.js')).adminRoleContract, Clusters: (await import('./contracts/index.js')).clusterContract, ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, Environments: (await import('./contracts/index.js')).environmentContract, Logs: (await import('./contracts/index.js')).logContract, PersonalAccessTokens: (await import('./contracts/index.js')) diff --git a/packages/shared/src/contracts/deployment.ts b/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..556e96d899 --- /dev/null +++ b/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: null, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/deployments`, +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 85488f73ac..c5c88fab16 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,6 +1,7 @@ export * from './admin-role.js' export * from './admin-token.js' export * from './cluster.js' +export * from './deployment.js' export * from './environment.js' export * from './log.js' export * from './personal-access-token.js' 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 = [{ diff --git a/playwright/e2e-tests/deployment.spec.ts b/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..ede7dd626d --- /dev/null +++ b/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console.js' +import { createDeployment } from 'helpers/deployment.js' +import { createEnvironment } from 'helpers/environment.js' +import { createProject, deleteProject } from 'helpers/project.js' +import { createRepository } from 'helpers/repository.js' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + test('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + test('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/playwright/helpers/deployment.ts b/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +}
+ {{ deployment.name }} +
+ {{ deployment.deploymentSources.length }} + + dépôts + + + dépôt + +
+ Un déploiement est lié à exactement 1 environnement +
Associez un environnement à un ou plusieurs dépôts pour configurer un déploiment.