Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 10 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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();
});
});
12 changes: 1 addition & 11 deletions src/app/project/app/[appId]/volumes/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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');
});

Expand Down
89 changes: 70 additions & 19 deletions src/app/project/app/[appId]/volumes/volume-backup-edit-overlay.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<any, any>,
payload: VolumeBackupEditModel) =>
saveBackupVolume(state, {
Expand All @@ -84,18 +91,22 @@ export default function VolumeBackupEditDialog({
}, [volumeBackup, volumes, s3Targets, form]);

return (
<>
<DialogHeader>
<DialogTitle>Edit Backup Configuration</DialogTitle>
<DialogDescription>
Configure the backup settings for this volume.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form action={() => form.handleSubmit((data) => {
return formAction(data);
}, console.error)()}>
<div className="space-y-4">
<Form {...form}>
<form
className="flex max-h-[calc(100dvh-3rem)] flex-col overflow-hidden"
action={() => form.handleSubmit(
(data) => formAction(data),
console.error,
)()}
>
<DialogHeader>
<DialogTitle>Edit Backup Configuration</DialogTitle>
<DialogDescription>
Configure the backup settings for this volume.
</DialogDescription>
</DialogHeader>
<ScrollArea className="mt-4 min-h-0 flex-1">
<div className="space-y-4 px-2">
<FormField
control={form.control}
name="cron"
Expand Down Expand Up @@ -177,12 +188,52 @@ export default function VolumeBackupEditDialog({
/>
)}

<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
</form>
</Form >
</>
<FormField
control={form.control}
name="backupBeforeDeployment"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<FormLabelWithQuestion hint="Run this backup automatically before a deployment is applied. This also applies when an app connected through a network policy is deployed.">
Backup before deployment
</FormLabelWithQuestion>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>

{backupBeforeDeployment && (
<FormField
control={form.control}
name="failSilently"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<FormLabelWithQuestion hint="Continue with the deployment even if this backup fails. When disabled, a failed backup aborts the deployment.">
Fail silently on deployment
</FormLabelWithQuestion>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}

<p className="text-red-500">{state.message}</p>
</div>
</ScrollArea>
<DialogFooter className="mt-4">
<SubmitButton>Save</SubmitButton>
</DialogFooter>
</form>
</Form>
)


Expand Down
Loading
Loading