mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-30 16:52:16 +00:00
Merge branch 'canary' into Change-backup-file-naming-structure-#2955
This commit is contained in:
@@ -4,28 +4,48 @@ import { findComposeById } from "@dokploy/server/services/compose";
|
||||
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
|
||||
export const getVolumeServiceAppName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
if (volumeBackup.compose?.appName) {
|
||||
return volumeBackup.serviceName
|
||||
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
|
||||
: volumeBackup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
volumeBackup.application?.appName ||
|
||||
volumeBackup.postgres?.appName ||
|
||||
volumeBackup.mysql?.appName ||
|
||||
volumeBackup.mariadb?.appName ||
|
||||
volumeBackup.mongo?.appName ||
|
||||
volumeBackup.redis?.appName ||
|
||||
volumeBackup.libsql?.appName;
|
||||
return serviceAppName || volumeBackup.appName;
|
||||
};
|
||||
|
||||
export const backupVolume = async (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
) => {
|
||||
const { serviceType, volumeName, turnOff, prefix } = volumeBackup;
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
|
||||
const destination = volumeBackup.destination;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace("T", "_")
|
||||
.replace(/:/g, "-")
|
||||
.replace(".", "_");
|
||||
const backupFileName = `${volumeName}-${timestamp}.tar`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
|
||||
const rcloneFlags = getS3Credentials(volumeBackup.destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
|
||||
|
||||
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
|
||||
|
||||
const baseCommand = `
|
||||
const backupCommand = `
|
||||
set -e
|
||||
echo "Volume name: ${volumeName}"
|
||||
echo "Backup file name: ${backupFileName}"
|
||||
@@ -38,6 +58,9 @@ export const backupVolume = async (
|
||||
ubuntu \
|
||||
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
|
||||
echo "Volume backup done ✅"
|
||||
`;
|
||||
|
||||
const uploadCommand = `
|
||||
echo "Starting upload to S3..."
|
||||
${rcloneCommand}
|
||||
echo "Upload to S3 done ✅"
|
||||
@@ -47,19 +70,63 @@ export const backupVolume = async (
|
||||
`;
|
||||
|
||||
if (!turnOff) {
|
||||
return baseCommand;
|
||||
return `
|
||||
${backupCommand}
|
||||
${uploadCommand}
|
||||
`;
|
||||
}
|
||||
|
||||
const serviceLockId =
|
||||
serviceType === "application"
|
||||
? volumeBackup.application?.appName
|
||||
: `${volumeBackup.compose?.appName}_${volumeBackup.serviceName}`;
|
||||
|
||||
const lockPath = `${VOLUME_BACKUP_LOCK_PATH}-${serviceLockId}`;
|
||||
|
||||
const lockWrapper = (body: string) => `
|
||||
set -e
|
||||
|
||||
LOCK_PATH="${lockPath}"
|
||||
|
||||
echo "Waiting for volume backup lock: $LOCK_PATH"
|
||||
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
exec 9>"$LOCK_PATH"
|
||||
flock 9
|
||||
else
|
||||
LOCK_DIR="$LOCK_PATH.dir"
|
||||
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
||||
echo "Waiting for volume backup lock: $LOCK_PATH"
|
||||
sleep 5
|
||||
done
|
||||
trap 'rm -rf "$LOCK_DIR"' EXIT
|
||||
fi
|
||||
|
||||
echo "Volume backup lock acquired"
|
||||
|
||||
${body}
|
||||
|
||||
echo "Volume backup lock released"
|
||||
`;
|
||||
|
||||
console.log(
|
||||
lockWrapper(`
|
||||
echo "Volume backup lock acquired"
|
||||
echo "Volume backup lock released"
|
||||
`),
|
||||
);
|
||||
|
||||
if (serviceType === "application") {
|
||||
return `
|
||||
return lockWrapper(`
|
||||
echo "Stopping application to 0 replicas"
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service scale ${volumeBackup.application?.appName}=0
|
||||
${baseCommand}
|
||||
docker service update --replicas=0 ${volumeBackup.application?.appName}
|
||||
${backupCommand}
|
||||
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||
docker service scale ${volumeBackup.application?.appName}=$ACTUAL_REPLICAS
|
||||
`;
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
if (serviceType === "compose") {
|
||||
const compose = await findComposeById(
|
||||
@@ -74,25 +141,28 @@ export const backupVolume = async (
|
||||
echo "Service name: ${compose.appName}_${volumeBackup.serviceName}"
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${compose.appName}_${volumeBackup.serviceName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service scale ${compose.appName}_${volumeBackup.serviceName}=0`;
|
||||
docker service update --replicas=0 ${compose.appName}_${volumeBackup.serviceName}`;
|
||||
|
||||
startCommand = `
|
||||
echo "Starting compose to $ACTUAL_REPLICAS replicas"
|
||||
docker service scale ${compose.appName}_${volumeBackup.serviceName}=$ACTUAL_REPLICAS`;
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${compose.appName}_${volumeBackup.serviceName}`;
|
||||
} else {
|
||||
stopCommand = `
|
||||
echo "Stopping compose container"
|
||||
ID=$(docker ps -q --filter "label=com.docker.compose.project=${compose.appName}" --filter "label=com.docker.compose.service=${volumeBackup.serviceName}")
|
||||
docker stop $ID`;
|
||||
|
||||
startCommand = `
|
||||
echo "Starting compose container"
|
||||
docker start $ID
|
||||
echo "Compose container started"
|
||||
`;
|
||||
}
|
||||
return `
|
||||
return lockWrapper(`
|
||||
${stopCommand}
|
||||
${baseCommand}
|
||||
${backupCommand}
|
||||
${startCommand}
|
||||
`;
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,55 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
import { backupVolume } from "./backup";
|
||||
import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
|
||||
import { backupVolume, getVolumeServiceAppName } from "./backup";
|
||||
|
||||
// Helper functions to extract project info from volume backup
|
||||
const getProjectName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
const services = [
|
||||
volumeBackup.application,
|
||||
volumeBackup.compose,
|
||||
volumeBackup.postgres,
|
||||
volumeBackup.mysql,
|
||||
volumeBackup.mariadb,
|
||||
volumeBackup.mongo,
|
||||
volumeBackup.redis,
|
||||
volumeBackup.libsql,
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
if (service?.environment?.project?.name) {
|
||||
return service.environment.project.name;
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown Project";
|
||||
};
|
||||
|
||||
const getOrganizationId = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
const services = [
|
||||
volumeBackup.application,
|
||||
volumeBackup.compose,
|
||||
volumeBackup.postgres,
|
||||
volumeBackup.mysql,
|
||||
volumeBackup.mariadb,
|
||||
volumeBackup.mongo,
|
||||
volumeBackup.redis,
|
||||
volumeBackup.libsql,
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
if (service?.environment?.project?.organizationId) {
|
||||
return service.environment.project.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const scheduleVolumeBackup = async (volumeBackupId: string) => {
|
||||
const volumeBackup = await findVolumeBackupById(volumeBackupId);
|
||||
@@ -35,9 +83,9 @@ const cleanupOldVolumeBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const normalizedPrefix = normalizeS3Path(prefix);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
|
||||
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
|
||||
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
|
||||
@@ -61,7 +109,8 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
title: "Volume Backup",
|
||||
description: "Volume Backup",
|
||||
});
|
||||
|
||||
const projectName = getProjectName(volumeBackup);
|
||||
const organizationId = getOrganizationId(volumeBackup);
|
||||
try {
|
||||
const command = await backupVolume(volumeBackup);
|
||||
|
||||
@@ -77,6 +126,28 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
}
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
|
||||
// Map service type to match notification function expectations
|
||||
const mappedServiceType =
|
||||
volumeBackup.serviceType === "mongo"
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup success notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const volumeBackupPath = path.join(
|
||||
@@ -92,6 +163,27 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
}
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
|
||||
console.error(error);
|
||||
// Send error notification
|
||||
const mappedServiceType =
|
||||
volumeBackup.serviceType === "mongo"
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup error notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user