311 lines · 9.9 KB · TypeScript
RawDownload
import { logger } from "@dokploy/server/lib/logger";import type { BackupSchedule } from "@dokploy/server/services/backup";import type { Destination } from "@dokploy/server/services/destination";import { scheduledJobs, scheduleJob } from "node-schedule";import { quote } from "shell-quote";import { keepLatestNBackups } from ".";import { runComposeBackup } from "./compose";import { runLibsqlBackup } from "./libsql";import { runMariadbBackup } from "./mariadb";import { runMongoBackup } from "./mongo";import { runMySqlBackup } from "./mysql";import { runPostgresBackup } from "./postgres";import { redactRcloneCredentials } from "./redact";import { runWebServerBackup } from "./web-server"; export const scheduleBackup = (backup: BackupSchedule) => {	const {		schedule,		backupId,		databaseType,		postgres,		mysql,		mongo,		mariadb,		libsql,		compose,	} = backup;	scheduleJob(backupId, schedule, async () => {		if (backup.backupType === "database") {			if (databaseType === "postgres" && postgres) {				await runPostgresBackup(postgres, backup);				await keepLatestNBackups(backup, postgres.serverId);			} else if (databaseType === "mysql" && mysql) {				await runMySqlBackup(mysql, backup);				await keepLatestNBackups(backup, mysql.serverId);			} else if (databaseType === "mongo" && mongo) {				await runMongoBackup(mongo, backup);				await keepLatestNBackups(backup, mongo.serverId);			} else if (databaseType === "mariadb" && mariadb) {				await runMariadbBackup(mariadb, backup);				await keepLatestNBackups(backup, mariadb.serverId);			} else if (databaseType === "libsql" && libsql) {				await runLibsqlBackup(libsql, backup);				await keepLatestNBackups(backup, libsql.serverId);			} else if (databaseType === "web-server") {				await runWebServerBackup(backup);				await keepLatestNBackups(backup);			}		} else if (backup.backupType === "compose" && compose) {			await runComposeBackup(compose, backup);			await keepLatestNBackups(backup, compose.serverId);		}	});}; export const removeScheduleBackup = (backupId: string) => {	const currentJob = scheduledJobs[backupId];	currentJob?.cancel();}; export const getBackupTimestamp = () =>	new Date().toISOString().replace(/[:.]/g, "-"); export const normalizeS3Path = (prefix: string) => {	// Trim whitespace and remove leading/trailing slashes	const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");	// Return empty string if prefix is empty, otherwise append trailing slash	return normalizedPrefix ? `${normalizedPrefix}/` : "";}; export const getS3Credentials = (destination: Destination) => {	const { accessKey, secretAccessKey, region, endpoint, provider } =		destination;	const rcloneFlags = [		`--s3-access-key-id="${accessKey}"`,		`--s3-secret-access-key="${secretAccessKey}"`,		`--s3-region="${region}"`,		`--s3-endpoint="${endpoint}"`,		"--s3-no-check-bucket",		"--s3-force-path-style",	]; 	if (provider) {		rcloneFlags.unshift(`--s3-provider="${provider}"`);	} 	if (destination.additionalFlags?.length) {		rcloneFlags.push(...destination.additionalFlags);	} 	return rcloneFlags;}; // User-controlled values (database name, user, password) are passed to the// container as environment variables via `docker exec -e VAR=<escaped>` and// referenced as "$VAR" inside the inner shell, so they never appear in the// inner command text. The -e value is escaped for the outer shell with// shell-quote; the inner script is single-quoted and reads the env vars.export const getPostgresBackupCommand = (	database: string,	databaseUser: string,) => {	return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;}; export const getMariadbBackupCommand = (	database: string,	databaseUser: string,	databasePassword: string,) => {	return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;}; export const getMysqlBackupCommand = (	database: string,	databasePassword: string,) => {	return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;}; export const getMongoBackupCommand = (	database: string,	databaseUser: string,	databasePassword: string,) => {	return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;}; export const getLibsqlBackupCommand = (database: string) => {	return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;}; export const getServiceContainerCommand = (appName: string) => {	return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;}; export const getComposeContainerCommand = (	appName: string,	serviceName: string,	composeType: "stack" | "docker-compose" | undefined,) => {	if (composeType === "stack") {		return `docker ps -q --filter "status=running" --filter "label=com.docker.stack.namespace=${appName}" --filter "label=com.docker.swarm.service.name=${appName}_${serviceName}" | head -n 1`;	}	return `docker ps -q --filter "status=running" --filter "label=com.docker.compose.project=${appName}" --filter "label=com.docker.compose.service=${serviceName}" | head -n 1`;}; const getContainerSearchCommand = (backup: BackupSchedule) => {	const {		backupType,		postgres,		mysql,		mariadb,		mongo,		libsql,		compose,		serviceName,	} = backup; 	if (backupType === "database") {		const appName =			postgres?.appName ||			mysql?.appName ||			mariadb?.appName ||			mongo?.appName ||			libsql?.appName;		return getServiceContainerCommand(appName || "");	}	if (backupType === "compose") {		const { appName, composeType } = compose || {};		return getComposeContainerCommand(			appName || "",			serviceName || "",			composeType,		);	}}; export const generateBackupCommand = (backup: BackupSchedule) => {	const { backupType, databaseType } = backup;	switch (databaseType) {		case "postgres": {			const postgres = backup.postgres;			if (backupType === "database" && postgres) {				return getPostgresBackupCommand(backup.database, postgres.databaseUser);			}			if (backupType === "compose" && backup.metadata?.postgres) {				return getPostgresBackupCommand(					backup.database,					backup.metadata.postgres.databaseUser,				);			}			break;		}		case "mysql": {			const mysql = backup.mysql;			if (backupType === "database" && mysql) {				return getMysqlBackupCommand(					backup.database,					mysql.databaseRootPassword,				);			}			if (backupType === "compose" && backup.metadata?.mysql) {				return getMysqlBackupCommand(					backup.database,					backup.metadata?.mysql?.databaseRootPassword || "",				);			}			break;		}		case "mariadb": {			const mariadb = backup.mariadb;			if (backupType === "database" && mariadb) {				return getMariadbBackupCommand(					backup.database,					mariadb.databaseUser,					mariadb.databasePassword,				);			}			if (backupType === "compose" && backup.metadata?.mariadb) {				return getMariadbBackupCommand(					backup.database,					backup.metadata.mariadb.databaseUser,					backup.metadata.mariadb.databasePassword,				);			}			break;		}		case "mongo": {			const mongo = backup.mongo;			if (backupType === "database" && mongo) {				return getMongoBackupCommand(					backup.database,					mongo.databaseUser,					mongo.databasePassword,				);			}			if (backupType === "compose" && backup.metadata?.mongo) {				return getMongoBackupCommand(					backup.database,					backup.metadata.mongo.databaseUser,					backup.metadata.mongo.databasePassword,				);			}			break;		}		case "libsql": {			if (backupType === "database") {				return getLibsqlBackupCommand(backup.database);			}			break;		}		default:			throw new Error(`Database type not supported: ${databaseType}`);	} 	return null;}; export const getBackupCommand = (	backup: BackupSchedule,	rcloneCommand: string,	logPath: string,) => {	const containerSearch = getContainerSearchCommand(backup);	const backupCommand = generateBackupCommand(backup); 	logger.info(		{			containerSearch,			backupCommand,			rcloneCommand: redactRcloneCredentials(rcloneCommand),			logPath,		},		`Executing backup command: ${backup.databaseType} ${backup.backupType}`,	); 	return `	set -eo pipefail;	echo "[$(date)] Starting backup process..." >> ${logPath};	echo "[$(date)] Executing backup command..." >> ${logPath};	CONTAINER_ID=$(${containerSearch}) 	if [ -z "$CONTAINER_ID" ]; then		echo "[$(date)] ❌ Error: Container not found" >> ${logPath};		exit 1;	fi 	echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath}; 	# Run the backup command and capture the exit status	BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {		echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};		echo "Error: $BACKUP_OUTPUT" >> ${logPath};		exit 1;	} 	echo "[$(date)] ✅ backup completed successfully" >> ${logPath};	echo "[$(date)] Starting upload to S3..." >> ${logPath}; 	# Run the upload command and capture the exit status	UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {		echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};		echo "Error: $UPLOAD_OUTPUT" >> ${logPath};		exit 1;	} 	echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};	echo "Backup done ✅" >> ${logPath};	`;};