From de390e85ee0240b4cdcde215a7bdb399987d95cb Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Thu, 24 Sep 2026 14:34:11 +0000 Subject: [PATCH 1/5] feat: backup app volumes before deployment Add automatic pre-deployment backups for volumes. Backup schedules can opt in with a new backupBeforeDeployment flag and a failSilently option in the existing volume backup settings. Before a deployment is applied, opted-in schedules of the deployed app and of its directly connected apps (1 hop through app network policies, same project) run one after another. Progress and errors are written to the deployment logs. A failed backup aborts the deployment unless it is marked failSilently. The trigger hooks into deployment.service.createDeployment, so UI deploys, rollbacks, webhooks and build-watch deployments are all covered. --- .../migration.sql | 22 +++ prisma/schema.prisma | 18 +- .../volume-backup.service.integration.spec.ts | 185 ++++++++++++++++++ .../project/app/[appId]/volumes/actions.ts | 12 +- .../volumes/volume-backup-edit-overlay.tsx | 52 +++++ .../app/[appId]/volumes/volume-backup.tsx | 6 + src/server/services/deployment.service.ts | 3 + .../services/deployment.service.unit.spec.ts | 3 + src/server/services/network-policy.service.ts | 45 +++++ .../standalone-services/backup.service.ts | 32 ++- src/server/services/volume-backup.service.ts | 74 +++++-- src/shared/model/backup-volume-edit.model.ts | 2 + .../model/generated-zod/volumebackup.ts | 2 + 13 files changed, 420 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20260924140414_add_pre_deployment_backup/migration.sql create mode 100644 src/__tests__/integration/server/services/volume-backup.service.integration.spec.ts diff --git a/prisma/migrations/20260924140414_add_pre_deployment_backup/migration.sql b/prisma/migrations/20260924140414_add_pre_deployment_backup/migration.sql new file mode 100644 index 00000000..e383562d --- /dev/null +++ b/prisma/migrations/20260924140414_add_pre_deployment_backup/migration.sql @@ -0,0 +1,22 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_VolumeBackup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "volumeId" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "cron" TEXT NOT NULL, + "retention" INTEGER NOT NULL, + "useDatabaseBackup" BOOLEAN NOT NULL DEFAULT false, + "backupBeforeDeployment" BOOLEAN NOT NULL DEFAULT false, + "failSilently" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "VolumeBackup_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "VolumeBackup_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "S3Target" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_VolumeBackup" ("createdAt", "cron", "id", "retention", "targetId", "updatedAt", "useDatabaseBackup", "volumeId") SELECT "createdAt", "cron", "id", "retention", "targetId", "updatedAt", "useDatabaseBackup", "volumeId" FROM "VolumeBackup"; +DROP TABLE "VolumeBackup"; +ALTER TABLE "new_VolumeBackup" RENAME TO "VolumeBackup"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 809ab7ec..fdbe8df8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -616,14 +616,16 @@ model S3Target { } model VolumeBackup { - id String @id @default(uuid()) - volumeId String - volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade) - targetId String - target S3Target @relation(fields: [targetId], references: [id], onDelete: Cascade) - cron String - retention Int - useDatabaseBackup Boolean @default(false) // decides wether backup whole volume or make a dump of the databse (only for database volumes) + id String @id @default(uuid()) + volumeId String + volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade) + targetId String + target S3Target @relation(fields: [targetId], references: [id], onDelete: Cascade) + cron String + retention Int + useDatabaseBackup Boolean @default(false) // decides wether backup whole volume or make a dump of the databse (only for database volumes) + backupBeforeDeployment Boolean @default(false) // run this backup automatically before a deployment is applied + failSilently Boolean @default(false) // continue the deployment even if this backup fails createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/__tests__/integration/server/services/volume-backup.service.integration.spec.ts b/src/__tests__/integration/server/services/volume-backup.service.integration.spec.ts new file mode 100644 index 00000000..56f40170 --- /dev/null +++ b/src/__tests__/integration/server/services/volume-backup.service.integration.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment node + +import mockNextJsCaching from '@/__tests__/nextjs-cache.utils'; +mockNextJsCaching(); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); +vi.mock('@/server/services/deployment-logs.service', () => ({ default: {}, dlog: vi.fn() })); + +import { createPrismaTestContext } from '@/__tests__/prisma-test.utils'; +import dataAccess from '@/server/adapter/db.client'; +import volumeBackupService from '@/server/services/volume-backup.service'; +import backupService from '@/server/services/standalone-services/backup.service'; +import { dlog } from '@/server/services/deployment-logs.service'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; + +const dlogMock = dlog as unknown as ReturnType; + +describe('volume-backup.service pre-deployment backups integration', () => { + createPrismaTestContext('volume-backup-service-pre-deployment'); + + const runBackupForSchedule = vi.fn(); + + beforeEach(() => { + runBackupForSchedule.mockReset(); + runBackupForSchedule.mockResolvedValue(undefined); + vi.spyOn(backupService, 'runBackupForSchedule').mockImplementation(runBackupForSchedule); + dlogMock.mockReset(); + dlogMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createApp(projectId: string, id: string) { + return dataAccess.client.app.create({ + data: { id, name: `app-${id}`, projectId }, + }); + } + + async function createBackup(appId: string, options: { + createdAt: Date; + backupBeforeDeployment: boolean; + failSilently?: boolean; + }) { + const target = await dataAccess.client.s3Target.create({ + data: { + name: `target-${appId}-${options.createdAt.getTime()}`, + bucketName: 'bucket', + endpoint: 'https://s3.example.com', + region: 'eu-west-1', + accessKeyId: 'key', + secretKey: 'secret', + }, + }); + const volume = await dataAccess.client.appVolume.create({ + data: { appId, containerMountPath: `/data-${options.createdAt.getTime()}`, size: 1 }, + }); + return dataAccess.client.volumeBackup.create({ + data: { + volumeId: volume.id, + targetId: target.id, + cron: '0 4 * * *', + retention: 3, + backupBeforeDeployment: options.backupBeforeDeployment, + failSilently: options.failSilently ?? false, + createdAt: options.createdAt, + }, + }); + } + + async function addRule(sourceAppId: string, targetAppId: string) { + const policy = await dataAccess.client.appNetworkPolicy.upsert({ + where: { appId: sourceAppId }, + create: { appId: sourceAppId }, + update: {}, + }); + await dataAccess.client.appNetworkPolicyRule.create({ + data: { + appNetworkPolicyId: policy.id, + type: 'EGRESS', + targetAppId, + port: 5432, + protocol: 'TCP', + }, + }); + } + + it('runs opted-in backups of the app and its directly connected apps in the same project', async () => { + const project = await dataAccess.client.project.create({ data: { name: 'P', projectType: 'APP' } }); + const app = await createApp(project.id, 'app-target'); + const connectedViaIncoming = await createApp(project.id, 'app-incoming'); + const connectedViaOutgoing = await createApp(project.id, 'app-outgoing'); + const unrelated = await createApp(project.id, 'app-unrelated'); + + const base = Date.now(); + const ownBackup = await createBackup(app.id, { createdAt: new Date(base), backupBeforeDeployment: true }); + const incomingBackup = await createBackup(connectedViaIncoming.id, { createdAt: new Date(base + 1), backupBeforeDeployment: true }); + const outgoingBackup = await createBackup(connectedViaOutgoing.id, { createdAt: new Date(base + 2), backupBeforeDeployment: true }); + await createBackup(unrelated.id, { createdAt: new Date(base + 3), backupBeforeDeployment: true }); + await createBackup(app.id, { createdAt: new Date(base + 4), backupBeforeDeployment: false }); + + // incoming: the peer points at the deployed app + await addRule(connectedViaIncoming.id, app.id); + // outgoing: the deployed app points at the peer + await addRule(app.id, connectedViaOutgoing.id); + + await volumeBackupService.runBackupsBeforeDeployment('dep-1', { + id: app.id, + projectId: project.id, + } as AppExtendedModel); + + expect(runBackupForSchedule.mock.calls.map(call => call[0])) + .toEqual([ownBackup.id, incomingBackup.id, outgoingBackup.id]); + }); + + it('ignores connected apps from other projects', async () => { + const project = await dataAccess.client.project.create({ data: { name: 'P', projectType: 'APP' } }); + const otherProject = await dataAccess.client.project.create({ data: { name: 'Other', projectType: 'APP' } }); + const app = await createApp(project.id, 'app-target'); + const foreignApp = await createApp(otherProject.id, 'app-foreign'); + + await createBackup(foreignApp.id, { createdAt: new Date(), backupBeforeDeployment: true }); + await addRule(foreignApp.id, app.id); + + await volumeBackupService.runBackupsBeforeDeployment('dep-1', { + id: app.id, + projectId: project.id, + } as AppExtendedModel); + + expect(runBackupForSchedule).not.toHaveBeenCalled(); + }); + + it('aborts the deployment when a non-silent backup fails, after running the remaining backups', async () => { + const project = await dataAccess.client.project.create({ data: { name: 'P', projectType: 'APP' } }); + const app = await createApp(project.id, 'app-target'); + + const base = Date.now(); + const failing = await createBackup(app.id, { createdAt: new Date(base), backupBeforeDeployment: true, failSilently: false }); + const remaining = await createBackup(app.id, { createdAt: new Date(base + 1), backupBeforeDeployment: true, failSilently: true }); + + runBackupForSchedule.mockImplementation(async (id: string) => { + if (id === failing.id) { + throw new Error('pod not running'); + } + }); + + await expect(volumeBackupService.runBackupsBeforeDeployment('dep-1', { + id: app.id, + projectId: project.id, + } as AppExtendedModel)).rejects.toThrow('Deployment aborted'); + + expect(runBackupForSchedule.mock.calls.map(call => call[0])) + .toEqual([failing.id, remaining.id]); + }); + + it('continues the deployment when a silent backup fails', async () => { + const project = await dataAccess.client.project.create({ data: { name: 'P', projectType: 'APP' } }); + const app = await createApp(project.id, 'app-target'); + + const failing = await createBackup(app.id, { createdAt: new Date(), backupBeforeDeployment: true, failSilently: true }); + runBackupForSchedule.mockRejectedValue(new Error('boom')); + + await expect(volumeBackupService.runBackupsBeforeDeployment('dep-1', { + id: app.id, + projectId: project.id, + } as AppExtendedModel)).resolves.toBeUndefined(); + + expect(runBackupForSchedule).toHaveBeenCalledWith(failing.id); + }); + + it('does nothing when no schedule opted in', async () => { + const project = await dataAccess.client.project.create({ data: { name: 'P', projectType: 'APP' } }); + const app = await createApp(project.id, 'app-target'); + await createBackup(app.id, { createdAt: new Date(), backupBeforeDeployment: false }); + + await volumeBackupService.runBackupsBeforeDeployment('dep-1', { + id: app.id, + projectId: project.id, + } as AppExtendedModel); + + expect(runBackupForSchedule).not.toHaveBeenCalled(); + expect(dlogMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/project/app/[appId]/volumes/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts index c06a65fb..dde12d51 100644 --- a/src/app/project/app/[appId]/volumes/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -11,7 +11,6 @@ import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model"; import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model"; import volumeBackupService from "@/server/services/volume-backup.service"; import backupService from "@/server/services/standalone-services/backup.service"; -import databaseBackupService from "@/server/services/standalone-services/database-backup.service"; import { volumeUploadZodModel } from "@/shared/model/volume-upload.model"; import restoreService from "@/server/services/restore.service"; import fileBrowserService from "@/server/services/file-browser-service"; @@ -168,16 +167,7 @@ export const deleteBackupVolume = async (backupVolumeId: string) => export const runBackupVolumeSchedule = async (backupVolumeId: string) => simpleAction(async () => { await validateBackupVolumeWriteAuthorization(backupVolumeId); - - const backupVolume = await volumeBackupService.getWithVolumeAndAppById(backupVolumeId); - - // Use database-specific backup if it's a database app AND useDatabaseBackup is true - if (backupVolume.volume.app.appType !== 'APP' && backupVolume.useDatabaseBackup) { - await databaseBackupService.backupDatabase(backupVolumeId); - } else { - await backupService.runBackupForVolume(backupVolumeId); - } - + await backupService.runBackupForSchedule(backupVolumeId); return new SuccessActionResult(undefined, 'Backup created and uploaded successfully'); }); diff --git a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx index 741e2462..75778415 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx @@ -59,9 +59,13 @@ export default function VolumeBackupEditDialog({ targetId: volumeBackup?.targetId || (s3Targets.length === 1 ? s3Targets[0].id : undefined), volumeId: volumeBackup?.volumeId || (volumes.length === 1 ? volumes[0].id : undefined), useDatabaseBackup: volumeBackup?.useDatabaseBackup ?? (isDatabaseApp && isDatabaseBackupSupported), + backupBeforeDeployment: volumeBackup?.backupBeforeDeployment ?? false, + failSilently: volumeBackup?.failSilently ?? false, } }); + const backupBeforeDeployment = form.watch('backupBeforeDeployment'); + const [state, formAction] = useActionState((state: ServerActionResult, payload: VolumeBackupEditModel) => saveBackupVolume(state, { @@ -177,6 +181,54 @@ export default function VolumeBackupEditDialog({ /> )} + ( + + + + +
+ + Backup before deployment + + + Run this backup automatically before a deployment. This also applies when a connected app is deployed. + +
+
+ )} + /> + + {backupBeforeDeployment && ( + ( + + + + +
+ + Fail silently + + + Continue with the deployment even if this backup fails. When disabled, a failed backup aborts the deployment. + +
+
+ )} + /> + )} +

{state.message}

Save diff --git a/src/app/project/app/[appId]/volumes/volume-backup.tsx b/src/app/project/app/[appId]/volumes/volume-backup.tsx index 8d6d6287..d205f3b5 100644 --- a/src/app/project/app/[appId]/volumes/volume-backup.tsx +++ b/src/app/project/app/[appId]/volumes/volume-backup.tsx @@ -81,6 +81,7 @@ export default function VolumeBackupList({ Retention Backup Method Backup Location + Before Deployment {(onBackupScheduleClick || !readonly) && } @@ -95,6 +96,11 @@ export default function VolumeBackupList({ : 'Archive of Volume'} {volumeBackup.target.name} + + {volumeBackup.backupBeforeDeployment + ? (volumeBackup.failSilently ? 'Yes (silent)' : 'Yes') + : 'No'} + {(onBackupScheduleClick || !readonly) &&
{onBackupScheduleClick &&