Merge branch 'canary' into Change-backup-file-naming-structure-#2955

This commit is contained in:
Mauricio Siu
2026-04-03 17:08:56 -06:00
723 changed files with 383978 additions and 20058 deletions
@@ -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,
);
}
}
};