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
38 changes: 27 additions & 11 deletions web/actions/backups.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
"use server";

import { desc, eq } from "drizzle-orm";
import { db } from "@/db";
import { volumeBackups, servers } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { deleteFromS3 } from "@/lib/s3";
import { db } from "@/db";
import { getBackupStorageConfig } from "@/db/queries";
import { servers, volumeBackups } from "@/db/schema";
import { triggerBackup } from "@/lib/backups/trigger-backup";
import { inngest } from "@/lib/inngest/client";
import { deleteFromS3 } from "@/lib/s3";

export async function createBackup(
serviceId: string,
volumeId: string,
backupTypeOverride?: "volume" | "database",
) {
const result = await triggerBackup({
serviceId,
volumeId,
backupTypeOverride,
});

await inngest.send({
name: "backup/trigger",
name: "backup/started",
data: {
backupId: result.backupId,
serviceId,
volumeId,
backupTypeOverride,
serverId: result.serverId,
},
});

revalidatePath(`/dashboard/projects`);
return { success: true };
return { success: true, backupId: result.backupId };
}

export async function listBackups(serviceId: string) {
Expand Down Expand Up @@ -66,23 +74,31 @@ export async function restoreBackup(

export async function deleteBackup(backupId: string) {
const backup = await db
.select({ storagePath: volumeBackups.storagePath })
.select({
status: volumeBackups.status,
storagePath: volumeBackups.storagePath,
})
.from(volumeBackups)
.where(eq(volumeBackups.id, backupId))
.then((r) => r[0]);

if (backup?.storagePath) {
await db.delete(volumeBackups).where(eq(volumeBackups.id, backupId));
revalidatePath(`/dashboard/projects`);

if (backup?.status === "completed" && backup.storagePath) {
const storageConfig = await getBackupStorageConfig();
if (storageConfig) {
try {
await deleteFromS3(storageConfig.bucket, backup.storagePath);
} catch (err) {
console.error("[deleteBackup] failed to delete from S3:", err);
console.error("[deleteBackup] failed to delete from S3:", {
backupId,
storagePath: backup.storagePath,
err,
});
}
}
}

await db.delete(volumeBackups).where(eq(volumeBackups.id, backupId));
revalidatePath(`/dashboard/projects`);
return { success: true };
}
18 changes: 8 additions & 10 deletions web/app/api/inngest/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { serve } from "inngest/next";
import { inngest } from "@/lib/inngest/client";
import {
backupWorkflow,
buildTriggerWorkflow,
buildWorkflow,
certificateRenewal,
challengeCleanup,
migrationWorkflow,
oldBackupsCleanup,
onBackupFailed,
onDeploymentFailed,
onRestoreFailed,
restoreTriggerWorkflow,
restoreWorkflow,
rolloutWorkflow,
scheduledBackupsCheck,
scheduledDeploymentsCheck,
staleItemsCleanup,
staleServerCheck,
migrationWorkflow,
backupWorkflow,
onBackupFailed,
restoreWorkflow,
onRestoreFailed,
buildWorkflow,
buildTriggerWorkflow,
backupTriggerWorkflow,
restoreTriggerWorkflow,
} from "@/lib/inngest/functions";

export const { GET, POST, PUT } = serve({
Expand All @@ -40,7 +39,6 @@ export const { GET, POST, PUT } = serve({
onRestoreFailed,
buildWorkflow,
buildTriggerWorkflow,
backupTriggerWorkflow,
restoreTriggerWorkflow,
],
});
67 changes: 6 additions & 61 deletions web/lib/backup-scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { and, desc, eq, lt } from "drizzle-orm";
import { deleteBackup } from "@/actions/backups";
import { db } from "@/db";
import {
services,
serviceVolumes,
volumeBackups,
deployments,
} from "@/db/schema";
import { eq, and, lt, desc } from "drizzle-orm";
import { getBackupStorageConfig } from "@/db/queries";
import { enqueueWork } from "@/lib/work-queue";
import { randomUUID } from "node:crypto";
import { services, serviceVolumes, volumeBackups } from "@/db/schema";
import { triggerBackup } from "@/lib/backups/trigger-backup";
import { DEFAULT_BACKUP_RETENTION_DAYS } from "@/lib/settings-keys";
import { deleteBackup } from "@/actions/backups";

function shouldRunSchedule(
schedule: string,
Expand Down Expand Up @@ -84,31 +78,6 @@ export async function runScheduledBackups() {
continue;
}

const deployment = await db
.select({
serverId: deployments.serverId,
containerId: deployments.containerId,
})
.from(deployments)
.where(
and(
eq(deployments.serviceId, service.id),
eq(deployments.status, "running"),
),
)
.then((r) => r[0]);

if (!deployment?.serverId) {
continue;
}

if (!deployment.containerId) {
console.error(
`[backup-scheduler] deployment for ${service.name} is missing container ID`,
);
continue;
}

for (const volume of volumes) {
const lastBackup = await db
.select({ createdAt: volumeBackups.createdAt })
Expand All @@ -132,33 +101,9 @@ export async function runScheduledBackups() {
continue;
}

const backupId = randomUUID();
const storagePath = `backups/${service.id}/${volume.name}/${backupId}.tar.gz`;

await db.insert(volumeBackups).values({
id: backupId,
volumeId: volume.id,
volumeName: volume.name,
await triggerBackup({
serviceId: service.id,
serverId: deployment.serverId,
status: "pending",
storagePath,
});

await enqueueWork(deployment.serverId, "backup_volume", {
backupId,
serviceId: service.id,
containerId: deployment.containerId,
volumeName: volume.name,
storagePath,
storageConfig: {
provider: storageConfig.provider,
bucket: storageConfig.bucket,
region: storageConfig.region,
endpoint: storageConfig.endpoint,
accessKey: storageConfig.accessKey,
secretKey: storageConfig.secretKey,
},
volumeId: volume.id,
});

console.log(
Expand Down
120 changes: 120 additions & 0 deletions web/lib/backups/trigger-backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "@/db";
import { getBackupStorageConfig } from "@/db/queries";
import {
deployments,
services,
serviceVolumes,
volumeBackups,
} from "@/db/schema";
import { detectDatabaseType } from "@/lib/database-utils";
import { enqueueWork } from "@/lib/work-queue";

type TriggerBackupInput = {
serviceId: string;
volumeId: string;
backupTypeOverride?: "volume" | "database";
};

function getDbBackupExtension(image: string): string {
const imageLower = image.toLowerCase();
if (imageLower.includes("postgres")) return ".dump";
if (imageLower.includes("mysql")) return ".sql";
if (imageLower.includes("mariadb")) return ".sql";
if (imageLower.includes("mongo")) return ".archive.gz";
if (imageLower.includes("redis")) return ".rdb";
return ".backup";
}

export async function triggerBackup({
serviceId,
volumeId,
backupTypeOverride,
}: TriggerBackupInput) {
const storageConfig = await getBackupStorageConfig();
if (!storageConfig) {
throw new Error("Backup storage not configured");
}

const volume = await db
.select()
.from(serviceVolumes)
.where(eq(serviceVolumes.id, volumeId))
.then((r) => r[0]);

if (!volume) {
throw new Error("Volume not found");
}

const service = await db
.select()
.from(services)
.where(eq(services.id, serviceId))
.then((r) => r[0]);

if (!service) {
throw new Error("Service not found");
}

const deployment = await db
.select({
id: deployments.id,
serverId: deployments.serverId,
containerId: deployments.containerId,
})
.from(deployments)
.where(
and(
eq(deployments.serviceId, serviceId),
eq(deployments.status, "running"),
),
)
.then((r) => r[0]);

if (!deployment || !deployment.serverId) {
throw new Error("No running deployment found for this service");
}

if (!deployment.containerId) {
throw new Error("Deployment is missing container ID");
}

const backupType =
backupTypeOverride ??
(detectDatabaseType(service.image) ? "database" : "volume");
const backupId = randomUUID();
const fileExtension =
backupType === "database" ? getDbBackupExtension(service.image) : ".tar.gz";
const storagePath = `backups/${serviceId}/${volume.name}/${backupId}${fileExtension}`;

await db.insert(volumeBackups).values({
id: backupId,
volumeId,
volumeName: volume.name,
serviceId,
serverId: deployment.serverId,
status: "pending",
storagePath,
});

await enqueueWork(deployment.serverId, "backup_volume", {
backupId,
serviceId,
containerId: deployment.containerId,
volumeName: volume.name,
storagePath,
backupType,
serviceImage: service.image,
storageConfig: {
provider: storageConfig.provider,
bucket: storageConfig.bucket,
region: storageConfig.region,
endpoint: storageConfig.endpoint,
accessKey: storageConfig.accessKey,
secretKey: storageConfig.secretKey,
},
});

return { backupId, serverId: deployment.serverId };
}
7 changes: 0 additions & 7 deletions web/lib/inngest/events/backup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
export type BackupEvents = {
"backup/trigger": {
data: {
serviceId: string;
volumeId: string;
backupTypeOverride?: "volume" | "database";
};
};
"backup/started": {
data: {
backupId: string;
Expand Down
Loading