From 99af2a8c7ab24dcaa4a568743e98b83389993b99 Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:23:08 +0200 Subject: [PATCH] feat(deployment): persist and validate deployment value sources --- .../deployment-datastore.service.spec.ts | 31 +- .../deployment-datastore.service.ts | 54 +-- .../deployment/deployment-testing.utils.ts | 53 ++- .../deployment/deployment.controller.spec.ts | 5 +- .../deployment/deployment.controller.ts | 10 +- .../deployment/deployment.service.spec.ts | 77 +++- .../modules/deployment/deployment.service.ts | 89 +++-- .../deployment/deployment.utils.spec.ts | 310 ++++++++++++++++ .../modules/deployment/deployment.utils.ts | 333 ++++++++++++++++++ packages/shared/src/schemas/deployment.ts | 94 ++++- 10 files changed, 936 insertions(+), 120 deletions(-) create mode 100644 apps/server-nestjs/src/modules/deployment/deployment.utils.spec.ts create mode 100644 apps/server-nestjs/src/modules/deployment/deployment.utils.ts 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 01267db740..9548c36b86 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 @@ -37,22 +37,28 @@ describe('deploymentDatastoreService', () => { service = module.get(DeploymentDatastoreService) }) + const deploymentRelations = { + environment: true, + deploymentSources: { + include: { + repository: true, + internalValueSources: { orderBy: { order: 'asc' } }, + externalValueSource: true, + }, + }, + } + describe('getDeploymentById', () => { - it('should return a deployment with sources and repository', async () => { + it('should return a deployment with sources and value sources', async () => { const deployment = { ...mockDeployment } - prisma.deployment.findUnique.mockResolvedValue(deployment) + prisma.deployment.findUniqueOrThrow.mockResolvedValue(deployment) const result = await service.getDeploymentById('dep1') - expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + expect(prisma.deployment.findUniqueOrThrow).toHaveBeenCalledWith({ where: { id: 'dep1' }, - include: { - environment: true, - deploymentSources: { - include: { repository: true }, - }, - }, + include: deploymentRelations, }) expect(result).toEqual(deployment) }) @@ -68,12 +74,7 @@ describe('deploymentDatastoreService', () => { expect(prisma.deployment.findMany).toHaveBeenCalledWith({ where: { projectId: 'project1' }, - include: { - environment: true, - deploymentSources: { - include: { repository: true }, - }, - }, + include: deploymentRelations, 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 859a299494..ec195203ec 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -1,54 +1,68 @@ -import type { Prisma } from '@prisma/client' +import type { Deployment, Prisma } from '@prisma/client' import { Inject, Injectable } from '@nestjs/common' import { PrismaService } from '../infrastructure/database/prisma.service' +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { + include: { + repository: true + internalValueSources: true + externalValueSource: true + } + } + } +}> + +const deploymentRelations = { + environment: true, + deploymentSources: { + include: { + repository: true, + internalValueSources: { orderBy: { order: 'asc' } }, + externalValueSource: true, + }, + }, +} satisfies Prisma.DeploymentInclude + @Injectable() export class DeploymentDatastoreService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} - getDeploymentById(deploymentId: string) { - return this.prisma.deployment.findUnique({ + getDeploymentById(deploymentId: string): Promise { + return this.prisma.deployment.findUniqueOrThrow({ where: { id: deploymentId }, - include: { - environment: true, - deploymentSources: { - include: { repository: true }, - }, - }, + include: deploymentRelations, }) } - getDeploymentsByProjectId(projectId: string) { + getDeploymentsByProjectId(projectId: string): Promise { return this.prisma.deployment.findMany({ where: { projectId }, - include: { - environment: true, - deploymentSources: { - include: { repository: true }, - }, - }, + include: deploymentRelations, orderBy: { createdAt: 'asc' }, }) } - createDeployment(data: Prisma.DeploymentCreateInput) { + createDeployment(data: Prisma.DeploymentCreateInput): Promise { return this.prisma.deployment.create({ data }) } - updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput): Promise { return this.prisma.deployment.update({ where: { id: deploymentId }, data, }) } - deleteDeployment(deploymentId: string) { + deleteDeployment(deploymentId: string): Promise { return this.prisma.deployment.delete({ where: { id: deploymentId }, }) } - deleteAllDeploymentsByProjectId(projectId: string) { + deleteAllDeploymentsByProjectId(projectId: string): Promise { return this.prisma.deployment.deleteMany({ where: { projectId }, }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts index 20f7e249a3..d107a3138e 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -1,4 +1,12 @@ -import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import type { + Deployment, + DeploymentExternalValueSource, + DeploymentInternalValueSource, + DeploymentSource, + Environment, + Prisma, + Repository, +} from '@prisma/client' import { faker } from '@faker-js/faker' export function makeDeployment(overrides: Partial = {}): Deployment { @@ -49,7 +57,38 @@ export function makeRepository(overrides: Partial = {}): Repository } } -type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } +type DeploymentSourceWithRepository = DeploymentSource & { + repository: Repository + internalValueSources: DeploymentInternalValueSource[] + externalValueSource: DeploymentExternalValueSource | null +} + +export function makeDeploymentInternalValueSource(overrides: Partial = {}): DeploymentInternalValueSource { + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentSourceId: faker.string.uuid(), + order: 0, + path: 'values.yaml', + ...overrides, + } +} + +export function makeDeploymentExternalValueSource(overrides: Partial = {}): DeploymentExternalValueSource { + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentSourceId: faker.string.uuid(), + order: 0, + path: 'values.yaml', + ref: 'main', + targetRevision: '', + repositoryId: faker.string.uuid(), + ...overrides, + } +} export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { const repositoryId = overrides.repositoryId ?? faker.string.uuid() @@ -64,6 +103,8 @@ export function makeDeploymentSource(overrides: Partial diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts index 53f28c259f..4173d53e0e 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -11,6 +11,7 @@ import { ProjectGuard } from '../infrastructure/permission/project/project.guard import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' import { DeploymentController } from './deployment.controller' import { DeploymentService } from './deployment.service' +import { serializeDeployment } from './deployment.utils' describe('deploymentController', () => { let module: TestingModule @@ -36,6 +37,7 @@ describe('deploymentController', () => { type: 'git', targetRevision: 'main', path: '/app', + valueSources: [], }, ], } satisfies CreateDeployment @@ -49,6 +51,7 @@ describe('deploymentController', () => { type: 'git', targetRevision: 'develop', path: '/updated-app', + valueSources: [], }, ], } satisfies UpdateDeployment @@ -75,7 +78,7 @@ describe('deploymentController', () => { describe('list', () => { it('should call deploymentService.listByProjectId with projectId', async () => { - const expectedResult = [makeDeploymentWithRelations({ projectId })] + const expectedResult = [makeDeploymentWithRelations({ projectId })].map(serializeDeployment) service.listByProjectId.mockResolvedValue(expectedResult) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/apps/server-nestjs/src/modules/deployment/deployment.controller.ts index d3a8706e01..d31fd4c29a 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.controller.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -1,7 +1,9 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { Deployment } from '@prisma/client' import type { FastifyRequest } from 'fastify' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { SerializedDeployment } from './deployment.utils' import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' import { AuthUser } from '../infrastructure/auth/auth-user.decorator' @@ -20,7 +22,7 @@ export class DeploymentController { @Get('') @RequireAdminPermission('ListProjects') @RequireProjectPermission('ListDeployments') - list(@Project() project: ProjectContext) { + list(@Project() project: ProjectContext): Promise { return this.deploymentService.listByProjectId(project.id) } @@ -32,7 +34,7 @@ export class DeploymentController { @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, - ) { + ): Promise { return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) } @@ -45,7 +47,7 @@ export class DeploymentController { @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, - ) { + ): Promise { return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) } @@ -57,7 +59,7 @@ export class DeploymentController { @Project() project: ProjectContext, @AuthUser() user: UserContext, @Req() request: FastifyRequest, - ) { + ): Promise { return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) } } diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts index 3b9e19c058..b200e7c4d8 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -8,6 +8,7 @@ import { AppEventsService } from '../events/app-events.service' import { DeploymentDatastoreService } from './deployment-datastore.service' import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' import { DeploymentService } from './deployment.service' +import { serializeDeployment } from './deployment.utils' describe('deploymentService', () => { let module: TestingModule @@ -34,6 +35,7 @@ describe('deploymentService', () => { targetRevision: 'main', path: '/app', helmValuesFiles: 'values.yaml', + valueSources: [], }, ], } satisfies CreateDeployment @@ -48,6 +50,7 @@ describe('deploymentService', () => { targetRevision: 'develop', path: '/updated-app', helmValuesFiles: 'updated-values.yaml', + valueSources: [], }, ], } satisfies UpdateDeployment @@ -79,7 +82,7 @@ describe('deploymentService', () => { const result = await service.listByProjectId(projectId) expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) - expect(result).toEqual(deployments) + expect(result).toEqual(deployments.map(serializeDeployment)) }) }) @@ -98,15 +101,14 @@ describe('deploymentService', () => { 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, - })), - }, + create: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repository: { connect: { id: source.repositoryId } }, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + internalValueSources: { create: [] }, + })), }, }) @@ -123,6 +125,52 @@ describe('deploymentService', () => { expect(result).toEqual(createdDeployment) }) + + it('should nest internal and external value sources', async () => { + const valueRepositoryId = '77777777-7777-7777-7777-777777777777' + const createWithValueSources = { + ...validCreateDeployment, + deploymentSources: [ + { + ...validCreateDeployment.deploymentSources[0], + valueSources: [ + { type: 'internal' as const, path: 'values.yaml' }, + { type: 'external' as const, ref: 'infra-values', path: 'values-.yaml', targetRevision: 'main', repositoryId: valueRepositoryId }, + ], + }, + ], + } satisfies CreateDeployment + + datastore.createDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.createDeployment(projectId, createWithValueSources, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + deploymentSources: { + create: [ + expect.objectContaining({ + internalValueSources: { + create: [ + { order: 0, path: 'values.yaml' }, + ], + }, + externalValueSource: { + create: { + order: 1, + path: 'values-.yaml', + ref: 'infra-values', + targetRevision: 'main', + repository: { connect: { id: valueRepositoryId } }, + }, + }, + }), + ], + }, + }), + ) + }) }) describe('updateDeployment', () => { @@ -152,7 +200,8 @@ describe('deploymentService', () => { deleteMany: { id: { in: ['66666666-6666-6666-6666-666666666666'] }, }, - upsert: expect.any(Array), + create: expect.any(Array), + update: expect.any(Array), }, }), ) @@ -161,12 +210,12 @@ describe('deploymentService', () => { expect(result).toEqual(updatedDeployment) }) - it('should throw if deployment does not exist', async () => { - datastore.getDeploymentById.mockResolvedValue(null) + it('should not update when the deployment does not exist', async () => { + datastore.getDeploymentById.mockRejectedValue(new Error('No Deployment found')) await expect( service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), - ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + ).rejects.toThrow('No Deployment found') expect(datastore.updateDeployment).not.toHaveBeenCalled() }) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/apps/server-nestjs/src/modules/deployment/deployment.service.ts index beec47e562..515575a0b3 100644 --- a/apps/server-nestjs/src/modules/deployment/deployment.service.ts +++ b/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -1,8 +1,17 @@ import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { Deployment } from '@prisma/client' import type { EventLogAction } from '../events/app-events.service' +import type { SerializedDeployment } from './deployment.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { AppEventsService } from '../events/app-events.service' import { DeploymentDatastoreService } from './deployment-datastore.service' +import { + buildDeploymentSourceCreate, + buildDeploymentSourceUpdate, + parseCreateDeployment, + parseUpdateDeployment, + serializeDeployment, +} from './deployment.utils' @Injectable() export class DeploymentService { @@ -13,26 +22,21 @@ export class DeploymentService { @Inject(AppEventsService) private readonly appEvents: AppEventsService, ) {} - async listByProjectId(projectId: string) { - return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + async listByProjectId(projectId: string): Promise { + const deployments = await this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + return deployments.map(serializeDeployment) } - async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string): Promise { + const model = parseCreateDeployment(deploymentToCreate) + const deployment = await this.deploymentDatastoreService.createDeployment({ - name: deploymentToCreate.name, + name: model.name, project: { connect: { id: projectId } }, - autosync: deploymentToCreate.autosync, - environment: { connect: { id: deploymentToCreate.environmentId } }, + autosync: model.autosync, + environment: { connect: { id: model.environmentId } }, deploymentSources: { - createMany: { - data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ - type, - repositoryId, - targetRevision, - path, - helmValuesFiles, - })), - }, + create: model.deploymentSources.map(buildDeploymentSourceCreate), }, }) @@ -40,44 +44,35 @@ export class DeploymentService { return deployment } - async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string): Promise { const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) - if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + const model = parseUpdateDeployment(deploymentToUpdate) - const incomingDeploymentSourceIds = new Set( - deploymentToUpdate.deploymentSources - .filter(s => s.id) - .map(s => s.id), - ) + const keptDeploymentSourceIds = new Set(model.deploymentSourcesToUpdate.map(source => source.id)) + const deploymentSourceIdsToDelete = existing.deploymentSources + .filter(source => !keptDeploymentSourceIds.has(source.id)) + .map(source => source.id) - const deploymentSourcesToDelete = existing.deploymentSources.filter( - e => !incomingDeploymentSourceIds.has(e.id), + // Tracks which existing deployment sources already own an external value + // source, so the update knows when it must delete a removed one. + const sourcesWithExternal = new Set( + existing.deploymentSources + .filter(source => source.externalValueSource) + .map(source => source.id), ) const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { - name: deploymentToUpdate.name, - autosync: deploymentToUpdate.autosync, - environment: { connect: { id: deploymentToUpdate.environmentId } }, + name: model.name, + autosync: model.autosync, + environment: { connect: { id: model.environmentId } }, deploymentSources: { deleteMany: { - id: { in: deploymentSourcesToDelete.map(s => s.id) }, + id: { in: deploymentSourceIdsToDelete }, }, - 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, - }, + create: model.deploymentSourcesToCreate.map(buildDeploymentSourceCreate), + update: model.deploymentSourcesToUpdate.map(source => ({ + where: { id: source.id }, + data: buildDeploymentSourceUpdate(source, sourcesWithExternal.has(source.id)), })), }, }) @@ -85,12 +80,12 @@ export class DeploymentService { return deployment } - async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string): Promise { await this.deploymentDatastoreService.deleteDeployment(deploymentId) this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) } - async deleteAllDeploymentsByProjectId(projectId: string) { + async deleteAllDeploymentsByProjectId(projectId: string): Promise { await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) } @@ -99,7 +94,7 @@ export class DeploymentService { * Triggers the project reconciliation without blocking the response: listener * outcomes (including failures) are persisted in the admin log by AppEventsService. */ - private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) .catch((error: unknown) => { this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.utils.spec.ts b/apps/server-nestjs/src/modules/deployment/deployment.utils.spec.ts new file mode 100644 index 0000000000..f7bba16394 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.utils.spec.ts @@ -0,0 +1,310 @@ +import type { + CreateDeployment, + CreateDeploymentSource, + CreateDeploymentValueSource, + UpdateDeployment, + UpdateDeploymentSource, + UpdateDeploymentValueSource, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { makeDeploymentExternalValueSource, makeDeploymentInternalValueSource, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { + buildDeploymentSourceCreate, + buildDeploymentSourceUpdate, + parseCreateDeployment, + parseUpdateDeployment, + serializeDeployment, +} from './deployment.utils' + +const repositoryId = faker.string.uuid() +const externalRepositoryId = faker.string.uuid() +const sourceId = faker.string.uuid() +const internalId = faker.string.uuid() + +// Common top-level fields; tests only care about the deploymentSources they pass. +function makeCreateDeployment(deploymentSources: CreateDeploymentSource[]): CreateDeployment { + return { + name: 'mydeployment', + projectId: faker.string.uuid(), + environmentId: faker.string.uuid(), + autosync: true, + deploymentSources, + } +} + +function makeUpdateDeployment(deploymentSources: UpdateDeploymentSource[]): UpdateDeployment { + return { ...makeCreateDeployment([]), deploymentSources } +} + +function updateSourceWith(valueSources: UpdateDeploymentValueSource[]) { + const deployment = makeUpdateDeployment([ + { + id: sourceId, + type: 'git', + repositoryId, + targetRevision: 'develop', + path: '/x', + helmValuesFiles: '', + valueSources, + }, + ]) + return parseUpdateDeployment(deployment).deploymentSourcesToUpdate[0] +} + +const internalValueSource = { type: 'internal', path: 'a.yaml' } satisfies CreateDeploymentValueSource +const externalValueSource = { + type: 'external', + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: externalRepositoryId, +} satisfies CreateDeploymentValueSource + +describe('deploymentUtils', () => { + describe('parseCreateDeployment', () => { + it('should carry the top-level fields (dropping projectId, which the service connects itself)', () => { + const deployment = makeCreateDeployment([{ type: 'git', repositoryId, valueSources: [] }]) + + const model = parseCreateDeployment(deployment) + + expect(model).toMatchObject({ name: deployment.name, autosync: deployment.autosync, environmentId: deployment.environmentId }) + expect(model).not.toHaveProperty('projectId') + }) + + it('should partition value sources into internal list and single external, preserving list order', () => { + const deployment = makeCreateDeployment([ + { + type: 'git', + repositoryId, + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + valueSources: [ + { type: 'internal', path: 'a.yaml' }, + externalValueSource, + { type: 'internal', path: 'b.yaml' }, + ], + }, + ]) + + const { valueSources } = parseCreateDeployment(deployment).deploymentSources[0] + + // `order` is the position in the original list, so the interleaving survives. + expect(valueSources.internal).toEqual([ + { path: 'a.yaml', order: 0 }, + { path: 'b.yaml', order: 2 }, + ]) + expect(valueSources.external).toEqual({ + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: externalRepositoryId, + order: 1, + }) + }) + + it('should leave external undefined when no external value source is provided', () => { + const deployment = makeCreateDeployment([{ type: 'git', repositoryId, valueSources: [internalValueSource] }]) + + const { valueSources } = parseCreateDeployment(deployment).deploymentSources[0] + + expect(valueSources.internal).toEqual([{ path: 'a.yaml', order: 0 }]) + expect(valueSources).not.toHaveProperty('external') + }) + }) + + describe('parseUpdateDeployment', () => { + it('should split deployment sources into create (no id) and update (id) buckets', () => { + const deployment = makeUpdateDeployment([ + { id: sourceId, type: 'git', repositoryId, valueSources: [] }, + { type: 'oci', repositoryId, valueSources: [] }, + ]) + + const model = parseUpdateDeployment(deployment) + + expect(model.deploymentSourcesToUpdate).toHaveLength(1) + expect(model.deploymentSourcesToUpdate[0]).toMatchObject({ id: sourceId, type: 'git' }) + expect(model.deploymentSourcesToCreate).toHaveLength(1) + expect(model.deploymentSourcesToCreate[0]).toMatchObject({ type: 'oci' }) + expect(model.deploymentSourcesToCreate[0]).not.toHaveProperty('id') + }) + + it('should split internal value sources by id and preserve order across the interleaved external', () => { + const deployment = makeUpdateDeployment([ + { id: sourceId, type: 'git', repositoryId, valueSources: [ + { type: 'internal', id: internalId, path: 'kept.yaml' }, + { type: 'internal', path: 'new.yaml' }, + externalValueSource, + ] }, + ]) + + const { valueSources } = parseUpdateDeployment(deployment).deploymentSourcesToUpdate[0] + + expect(valueSources.internalToUpdate).toEqual([{ id: internalId, path: 'kept.yaml', order: 0 }]) + expect(valueSources.internalToCreate).toEqual([{ path: 'new.yaml', order: 1 }]) + expect(valueSources.external).toEqual({ + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: externalRepositoryId, + order: 2, + }) + }) + + it('should parse the value sources of a new (no id) deployment source as fresh creates', () => { + const deployment = makeUpdateDeployment([{ type: 'git', repositoryId, valueSources: [internalValueSource] }]) + + const { valueSources } = parseUpdateDeployment(deployment).deploymentSourcesToCreate[0] + + expect(valueSources.internal).toEqual([{ path: 'a.yaml', order: 0 }]) + }) + }) + + describe('buildDeploymentSourceCreate', () => { + it('should build the nested create write with internal and external value sources', () => { + const deployment = makeCreateDeployment([ + { + type: 'git', + repositoryId, + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + valueSources: [internalValueSource, externalValueSource], + }, + ]) + + const source = parseCreateDeployment(deployment).deploymentSources[0] + + expect(buildDeploymentSourceCreate(source)).toEqual({ + type: 'git', + repository: { connect: { id: repositoryId } }, + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + internalValueSources: { create: [{ path: 'a.yaml', order: 0 }] }, + externalValueSource: { + create: { + order: 1, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repository: { connect: { id: externalRepositoryId } }, + }, + }, + }) + }) + + it('should omit externalValueSource entirely when there is none', () => { + const deployment = makeCreateDeployment([{ type: 'git', repositoryId, valueSources: [internalValueSource] }]) + + const result = buildDeploymentSourceCreate(parseCreateDeployment(deployment).deploymentSources[0]) + + expect(result.internalValueSources).toEqual({ create: [{ path: 'a.yaml', order: 0 }] }) + expect(result).not.toHaveProperty('externalValueSource') + }) + }) + + describe('buildDeploymentSourceUpdate', () => { + it('should create/update internal sources by id and delete the ones no longer sent', () => { + const source = updateSourceWith([ + { type: 'internal', id: internalId, path: 'kept.yaml' }, + { type: 'internal', path: 'new.yaml' }, + ]) + + expect(buildDeploymentSourceUpdate(source, false).internalValueSources).toEqual({ + deleteMany: { id: { notIn: [internalId] } }, + create: [{ path: 'new.yaml', order: 1 }], + update: [ + { + where: { id: internalId }, + data: { path: 'kept.yaml', order: 0 }, + }, + ], + }) + }) + + it('should upsert the external value source when present', () => { + const source = updateSourceWith([externalValueSource]) + const externalWrite = { + order: 0, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repository: { connect: { id: externalRepositoryId } }, + } + + expect(buildDeploymentSourceUpdate(source, false).externalValueSource).toEqual({ upsert: { update: externalWrite, create: externalWrite } }) + }) + + it('should delete the external value source when it was removed and one existed', () => { + const source = updateSourceWith([internalValueSource]) + + expect(buildDeploymentSourceUpdate(source, true).externalValueSource).toEqual({ delete: true }) + }) + + it('should leave the external value source untouched when none existed and none was sent', () => { + const source = updateSourceWith([internalValueSource]) + + expect(buildDeploymentSourceUpdate(source, false).externalValueSource).toEqual({}) + }) + }) + + describe('serializeDeployment', () => { + it('should merge the two value source relations into one list ordered by `order`', () => { + const internalA = makeDeploymentInternalValueSource({ order: 0, path: 'a.yaml' }) + const internalB = makeDeploymentInternalValueSource({ order: 2, path: 'b.yaml' }) + const external = makeDeploymentExternalValueSource({ + order: 1, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: externalRepositoryId, + }) + const deployment = makeDeploymentWithRelations({ + deploymentSources: [makeDeploymentSource({ + internalValueSources: [internalA, internalB], + externalValueSource: external, + })], + }) + + const [source] = serializeDeployment(deployment).deploymentSources + + expect(source.valueSources).toEqual([ + { type: 'internal', id: internalA.id, order: 0, path: 'a.yaml' }, + { + type: 'external', + id: external.id, + order: 1, + path: 'ext.yaml', + ref: 'infra', + targetRevision: 'main', + repositoryId: externalRepositoryId, + }, + { type: 'internal', id: internalB.id, order: 2, path: 'b.yaml' }, + ]) + }) + + it('should drop the persisted relations from the serialized source', () => { + const deployment = makeDeploymentWithRelations({ + deploymentSources: [makeDeploymentSource({ + internalValueSources: [makeDeploymentInternalValueSource()], + externalValueSource: null, + })], + }) + + const [source] = serializeDeployment(deployment).deploymentSources + + expect(source).not.toHaveProperty('internalValueSources') + expect(source).not.toHaveProperty('externalValueSource') + }) + + it('should yield an empty list when a source has no value sources', () => { + const deployment = makeDeploymentWithRelations({ + deploymentSources: [makeDeploymentSource({ internalValueSources: [], externalValueSource: null })], + }) + + expect(serializeDeployment(deployment).deploymentSources[0].valueSources).toEqual([]) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/deployment/deployment.utils.ts b/apps/server-nestjs/src/modules/deployment/deployment.utils.ts new file mode 100644 index 0000000000..974c8e3384 --- /dev/null +++ b/apps/server-nestjs/src/modules/deployment/deployment.utils.ts @@ -0,0 +1,333 @@ +import type { + CreateDeployment, + CreateDeploymentSource, + CreateDeploymentValueSource, + DeploymentValueSource, + UpdateDeployment, + UpdateDeploymentSource, + UpdateDeploymentValueSource, +} from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { DeploymentWithRelations } from './deployment-datastore.service' + +// --------------------------------------------------------------------------- +// Precise write model (the "parse, don't validate" boundary representation) +// +// The wire payloads (CreateDeployment / UpdateDeployment) carry value sources as +// a single ordered list discriminated by `type`, with an optional `id` on update +// entries. That shape is ergonomic for clients but imprecise for the server: it +// permits states the domain forbids (many externals) and states we must branch on +// later (id present or not). These types are the precise representation the rest +// of the module operates on. Producing them once, up front (see the `parse*` +// functions), makes two facts structural rather than runtime concerns: +// - a deployment source has at most one external value source (single optional +// field, not a list); +// - an entry is either created or updated (separate buckets, no optional id and +// no fabricated placeholder ids downstream). +// --------------------------------------------------------------------------- + +interface InternalValueSource { + path: string + order: number +} + +interface ExternalValueSource { + path: string + ref: string + targetRevision: string + repositoryId: string + order: number +} + +interface CreateValueSources { + internal: InternalValueSource[] + external?: ExternalValueSource +} + +interface UpdateValueSources { + internalToCreate: InternalValueSource[] + internalToUpdate: (InternalValueSource & { id: string })[] + external?: ExternalValueSource +} + +interface DeploymentSourceFields { + type: CreateDeploymentSource['type'] + repositoryId: string + targetRevision?: string + path?: string + helmValuesFiles?: string +} + +interface CreateDeploymentSourceModel extends DeploymentSourceFields { + valueSources: CreateValueSources +} + +interface UpdateDeploymentSourceModel extends DeploymentSourceFields { + id: string + valueSources: UpdateValueSources +} + +export interface CreateDeploymentModel { + name: string + autosync: boolean + environmentId: string + deploymentSources: CreateDeploymentSourceModel[] +} + +export interface UpdateDeploymentModel { + name: string + autosync: boolean + environmentId: string + deploymentSourcesToCreate: CreateDeploymentSourceModel[] + deploymentSourcesToUpdate: UpdateDeploymentSourceModel[] +} + +// `order` is the entry's position in the client-submitted list, preserving the +// interleaving between internal and external value sources (both are merged by +// `order` when the deployment is rendered). +function toExternalValueSource(valueSource: Extract, order: number): ExternalValueSource { + return { + path: valueSource.path, + ref: valueSource.ref, + targetRevision: valueSource.targetRevision, + repositoryId: valueSource.repositoryId, + order, + } +} + +function parseCreateValueSources(valueSources: CreateDeploymentValueSource[]): CreateValueSources { + const internal: InternalValueSource[] = [] + let external: ExternalValueSource | undefined + + valueSources.forEach((valueSource, order) => { + if (valueSource.type === 'internal') { + internal.push({ path: valueSource.path, order }) + } else { + external = toExternalValueSource(valueSource, order) + } + }) + + return external ? { internal, external } : { internal } +} + +function parseUpdateValueSources(valueSources: UpdateDeploymentValueSource[]): UpdateValueSources { + const internalToCreate: InternalValueSource[] = [] + const internalToUpdate: (InternalValueSource & { id: string })[] = [] + let external: ExternalValueSource | undefined + + valueSources.forEach((valueSource, order) => { + if (valueSource.type === 'internal') { + if (valueSource.id) { + internalToUpdate.push({ id: valueSource.id, path: valueSource.path, order }) + } else { + internalToCreate.push({ path: valueSource.path, order }) + } + } else { + external = toExternalValueSource(valueSource, order) + } + }) + + return external ? { internalToCreate, internalToUpdate, external } : { internalToCreate, internalToUpdate } +} + +function toDeploymentSourceFields(source: CreateDeploymentSource | UpdateDeploymentSource): DeploymentSourceFields { + return { + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + } +} + +// Boundary parse for a creation payload: every value source is a fresh insert. +export function parseCreateDeployment(deployment: CreateDeployment): CreateDeploymentModel { + return { + name: deployment.name, + autosync: deployment.autosync, + environmentId: deployment.environmentId, + deploymentSources: deployment.deploymentSources.map(source => ({ + ...toDeploymentSourceFields(source), + valueSources: parseCreateValueSources(source.valueSources), + })), + } +} + +// Boundary parse for an update payload: deployment sources (and their value +// sources) are split by whether the client sent an id. +export function parseUpdateDeployment(deployment: UpdateDeployment): UpdateDeploymentModel { + const deploymentSourcesToCreate: CreateDeploymentSourceModel[] = [] + const deploymentSourcesToUpdate: UpdateDeploymentSourceModel[] = [] + + for (const source of deployment.deploymentSources) { + if (source.id) { + deploymentSourcesToUpdate.push({ + ...toDeploymentSourceFields(source), + id: source.id, + valueSources: parseUpdateValueSources(source.valueSources), + }) + } else { + deploymentSourcesToCreate.push({ + ...toDeploymentSourceFields(source), + valueSources: parseCreateValueSources(source.valueSources), + }) + } + } + + return { + name: deployment.name, + autosync: deployment.autosync, + environmentId: deployment.environmentId, + deploymentSourcesToCreate, + deploymentSourcesToUpdate, + } +} + +// --------------------------------------------------------------------------- +// Prisma write builders — pure translations of the precise model above into +// nested-write inputs. They perform no partitioning or disambiguation; that has +// already happened at the boundary. +// --------------------------------------------------------------------------- + +function mapInternalCreate({ path, order }: InternalValueSource): Prisma.DeploymentInternalValueSourceCreateWithoutDeploymentSourceInput { + return { path, order } +} + +function mapExternalCreate(external: ExternalValueSource): Prisma.DeploymentExternalValueSourceCreateWithoutDeploymentSourceInput { + return { + order: external.order, + path: external.path, + ref: external.ref, + targetRevision: external.targetRevision, + repository: { connect: { id: external.repositoryId } }, + } +} + +function mapExternalUpdate(external: ExternalValueSource): Prisma.DeploymentExternalValueSourceUpdateWithoutDeploymentSourceInput { + return mapExternalCreate(external) +} + +interface ValueSourcesCreate { + internalValueSources: Prisma.DeploymentInternalValueSourceCreateNestedManyWithoutDeploymentSourceInput + externalValueSource?: Prisma.DeploymentExternalValueSourceCreateNestedOneWithoutDeploymentSourceInput +} + +interface ValueSourcesUpdate { + internalValueSources: Prisma.DeploymentInternalValueSourceUpdateManyWithoutDeploymentSourceNestedInput + externalValueSource: Prisma.DeploymentExternalValueSourceUpdateOneWithoutDeploymentSourceNestedInput +} + +function buildValueSourcesCreate(valueSources: CreateValueSources): ValueSourcesCreate { + return { + internalValueSources: { create: valueSources.internal.map(mapInternalCreate) }, + ...(valueSources.external ? { externalValueSource: { create: mapExternalCreate(valueSources.external) } } : {}), + } +} + +// Reconciles an existing deployment source's value sources. Internal sources are +// created/updated by id (deleteMany drops the ones the client no longer sends); +// the single external source is upserted, or deleted when the client removed it — +// `hadExternal` guards the delete, which Prisma rejects on a missing relation. +function buildValueSourcesUpdate(valueSources: UpdateValueSources, hadExternal: boolean): ValueSourcesUpdate { + const keptInternalIds = valueSources.internalToUpdate.map(({ id }) => id) + + let externalValueSource: Prisma.DeploymentExternalValueSourceUpdateOneWithoutDeploymentSourceNestedInput + if (valueSources.external) { + externalValueSource = { upsert: { update: mapExternalUpdate(valueSources.external), create: mapExternalCreate(valueSources.external) } } + } else if (hadExternal) { + externalValueSource = { delete: true } + } else { + externalValueSource = {} + } + + return { + internalValueSources: { + deleteMany: { id: { notIn: keptInternalIds } }, + create: valueSources.internalToCreate.map(mapInternalCreate), + update: valueSources.internalToUpdate.map(({ id, path, order }) => ({ + where: { id }, + data: { path, order }, + })), + }, + externalValueSource, + } +} + +// Nested-write payload for a freshly created deployment source. +export function buildDeploymentSourceCreate(source: CreateDeploymentSourceModel): Prisma.DeploymentSourceCreateWithoutDeploymentInput { + return { + type: source.type, + repository: { connect: { id: source.repositoryId } }, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + ...buildValueSourcesCreate(source.valueSources), + } +} + +// Nested-write payload reconciling an existing deployment source. `hadExternal` +// reflects whether the persisted source currently owns an external value source. +export function buildDeploymentSourceUpdate(source: UpdateDeploymentSourceModel, hadExternal: boolean): Prisma.DeploymentSourceUpdateWithoutDeploymentInput { + return { + type: source.type, + repository: { connect: { id: source.repositoryId } }, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + ...buildValueSourcesUpdate(source.valueSources, hadExternal), + } +} + +// --------------------------------------------------------------------------- +// Read serialization — the API exposes value sources the same way it accepts +// them: a single ordered, discriminated list. The two persisted relations are a +// storage detail, merged here (by `order`) once, server-side, so no consumer has +// to reconstruct the list. +// --------------------------------------------------------------------------- + +// A deployment source as loaded from the database, with the relations the read +// serialization needs. +type PersistedDeploymentSource = Prisma.DeploymentSourceGetPayload<{ + include: { + repository: true + internalValueSources: true + externalValueSource: true + } +}> + +type SerializedDeploymentSource = Omit & { valueSources: DeploymentValueSource[] } +export type SerializedDeployment = Omit & { deploymentSources: SerializedDeploymentSource[] } + +function mergeValueSources(source: PersistedDeploymentSource): DeploymentValueSource[] { + const internal = source.internalValueSources.map((valueSource): DeploymentValueSource => ({ + type: 'internal', + id: valueSource.id, + order: valueSource.order, + path: valueSource.path, + })) + const external: DeploymentValueSource[] = source.externalValueSource + ? [{ + type: 'external', + id: source.externalValueSource.id, + order: source.externalValueSource.order, + path: source.externalValueSource.path, + ref: source.externalValueSource.ref, + targetRevision: source.externalValueSource.targetRevision, + repositoryId: source.externalValueSource.repositoryId, + }] + : [] + + return [...internal, ...external].sort((a, b) => a.order - b.order) +} + +// Reshapes a persisted deployment into the API read shape: each source's two value +// source relations collapse into a single ordered `valueSources` list. +export function serializeDeployment(deployment: DeploymentWithRelations): SerializedDeployment { + return { + ...deployment, + deploymentSources: deployment.deploymentSources.map(({ internalValueSources, externalValueSource, ...source }) => ({ + ...source, + valueSources: mergeValueSources({ internalValueSources, externalValueSource, ...source }), + })), + } +} diff --git a/packages/shared/src/schemas/deployment.ts b/packages/shared/src/schemas/deployment.ts index b1e0f8ee47..6099237b56 100644 --- a/packages/shared/src/schemas/deployment.ts +++ b/packages/shared/src/schemas/deployment.ts @@ -7,6 +7,34 @@ import { RepoSchema } from './repository.js' const DeploymentSourceType = z.enum(['git', 'oci']) +const internalValueSourceFields = { + path: z.string(), +} + +const externalValueSourceFields = { + path: z.string(), + ref: z.string().min(1, 'Une source de valeurs externe doit avoir un nom de référence'), + targetRevision: z.string().default(''), + repositoryId: z.string().uuid('Une source de valeurs externe doit référencer un dépôt'), +} + +export const DeploymentInternalValueSourceSchema = z.object({ + id: z.string().uuid(), + order: z.number().int(), + ...internalValueSourceFields, +}) + +export const DeploymentExternalValueSourceSchema = z.object({ + id: z.string().uuid(), + order: z.number().int(), + ...externalValueSourceFields, +}) + +export const DeploymentValueSourceSchema = z.discriminatedUnion('type', [ + DeploymentInternalValueSourceSchema.extend({ type: z.literal('internal') }), + DeploymentExternalValueSourceSchema.extend({ type: z.literal('external') }), +]) + export const DeploymentSourceSchema = z.object({ id: z.string() .uuid(), @@ -20,6 +48,7 @@ export const DeploymentSourceSchema = z.object({ targetRevision: z.string().optional(), path: z.string().optional(), helmValuesFiles: z.string().optional(), + valueSources: DeploymentValueSourceSchema.array().default([]), }).extend(AtDatesToStringExtend) export const DeploymentSchema = z.object({ @@ -38,33 +67,66 @@ export const DeploymentSchema = z.object({ deploymentSources: DeploymentSourceSchema.array(), }).extend(AtDatesToStringExtend) +export const CreateDeploymentValueSourceSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('internal'), ...internalValueSourceFields }), + z.object({ type: z.literal('external'), ...externalValueSourceFields }), +]) +export const UpdateDeploymentValueSourceSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('internal'), id: z.string().uuid().optional(), ...internalValueSourceFields }), + z.object({ type: z.literal('external'), id: z.string().uuid().optional(), ...externalValueSourceFields }), +]) + +// A deployment source may carry at most one external value source (internal ones are unlimited). +function refineSingleExternalValueSource(valueSources: { type: 'internal' | 'external' }[], ctx: Zod.RefinementCtx): void { + if (valueSources.filter(valueSource => valueSource.type === 'external').length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Une seule source de valeurs externe est autorisée par source de déploiement', + }) + } +} + +const writableDeploymentSourceOmit = { + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + valueSources: true, +} as const + +// First-class writable deployment source schemas, built on the value source +// definitions above. CreateDeploymentSchema / UpdateDeploymentSchema compose these. +export const CreateDeploymentSourceSchema = DeploymentSourceSchema.omit(writableDeploymentSourceOmit).extend({ + valueSources: CreateDeploymentValueSourceSchema.array().superRefine(refineSingleExternalValueSource).default([]), +}) + +export const UpdateDeploymentSourceSchema = CreateDeploymentSourceSchema.extend({ + id: z.string().uuid().optional(), + valueSources: UpdateDeploymentValueSourceSchema.array().superRefine(refineSingleExternalValueSource).default([]), +}) + 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'), + deploymentSources: CreateDeploymentSourceSchema.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'), + deploymentSources: UpdateDeploymentSourceSchema.array().min(1, 'Au moins une source de déploiement est requise'), }) +export type DeploymentInternalValueSource = Zod.infer +export type DeploymentExternalValueSource = Zod.infer +export type DeploymentValueSource = Zod.infer +export type DeploymentSource = Zod.infer +export type CreateDeploymentValueSource = Zod.infer +export type UpdateDeploymentValueSource = Zod.infer +export type CreateDeploymentSource = Zod.infer +export type UpdateDeploymentSource = Zod.infer export type Deployment = Zod.infer export type CreateDeployment = Zod.infer export type UpdateDeployment = Zod.infer