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..2f18a4bb 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 @@ -1,7 +1,7 @@ 'use client' import type { z } from "zod"; -import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Form, FormControl, @@ -26,8 +26,11 @@ import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/ import SelectFormField from "@/components/custom/select-form-field" import Link from "next/link" import { Checkbox } from "@/components/ui/checkbox" +import { Switch } from "@/components/ui/switch" +import FormLabelWithQuestion from "@/components/custom/form-label-with-question" import { AppExtendedModel } from "@/shared/model/app-extended.model" import { useDialog } from "@/frontend/states/zustand.states"; +import { ScrollArea } from "@/components/ui/scroll-area"; export default function VolumeBackupEditDialog({ volumeBackup, @@ -59,9 +62,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, { @@ -84,18 +91,22 @@ export default function VolumeBackupEditDialog({ }, [volumeBackup, volumes, s3Targets, form]); return ( - <> - - Edit Backup Configuration - - Configure the backup settings for this volume. - - -
- form.handleSubmit((data) => { - return formAction(data); - }, console.error)()}> -
+ + form.handleSubmit( + (data) => formAction(data), + console.error, + )()} + > + + Edit Backup Configuration + + Configure the backup settings for this volume. + + + +
)} -

{state.message}

- Save -
- - - + ( + + + Backup before deployment + + + + + + )} + /> + + {backupBeforeDeployment && ( + ( + + + Fail silently on 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..97d8b5fb 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 + Run 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 &&