mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import { logger } from "@dokploy/server/lib/logger";
|
|
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
|
import { quote } from "shell-quote";
|
|
import { ExecError, execAsync, execAsyncRemote } from "../process/execAsync";
|
|
import { getRemoteDocker } from "../servers/remote-docker";
|
|
import { redactRcloneCredentials } from "./redact";
|
|
import {
|
|
BACKUP_WORKER_CONTAINER_NOT_FOUND_EXIT_CODE,
|
|
getBackupCommand,
|
|
getContainerSearchCommand,
|
|
} from "./utils";
|
|
import {
|
|
BackupWorkerPreStartError,
|
|
BackupWorkerTaskError,
|
|
type DockerClient,
|
|
findRunningServiceTask,
|
|
getBackupResourceNames,
|
|
getBackupTargetServiceName,
|
|
getBackupWorkerServiceSpec,
|
|
ReplacementServiceTaskNotFoundError,
|
|
RunningServiceTaskNotFoundError,
|
|
waitForBackupWorkerTask,
|
|
waitForReplacementServiceTask,
|
|
} from "./worker";
|
|
|
|
const MAX_BACKUP_WORKER_ATTEMPTS = 2;
|
|
|
|
type ExecuteBackupInput = {
|
|
backup: BackupSchedule;
|
|
executionId: string;
|
|
logPath: string;
|
|
rcloneDestination: string;
|
|
rcloneFlags: string[];
|
|
serverId?: string | null;
|
|
};
|
|
|
|
const runHostCommand = (
|
|
serverId: string | null | undefined,
|
|
command: string,
|
|
) => {
|
|
if (serverId) {
|
|
return execAsyncRemote(serverId, command);
|
|
}
|
|
|
|
return execAsync(command, { shell: "/bin/bash" });
|
|
};
|
|
|
|
const isNotFoundError = (error: unknown) =>
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"statusCode" in error &&
|
|
error.statusCode === 404;
|
|
|
|
const getSafeErrorMessage = (error: unknown) => {
|
|
if (error instanceof ExecError) {
|
|
const output = error.stderr?.trim() || error.stdout?.trim();
|
|
return redactRcloneCredentials(
|
|
output || `command exited with code ${error.exitCode ?? "unknown"}`,
|
|
);
|
|
}
|
|
return redactRcloneCredentials(
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
};
|
|
|
|
const getPublicExecErrorMessage = (error: ExecError) =>
|
|
`Backup command failed with exit code ${error.exitCode ?? "unknown"}`;
|
|
|
|
const appendBackupError = async (
|
|
serverId: string | null | undefined,
|
|
logPath: string,
|
|
error: unknown,
|
|
) => {
|
|
const message = getSafeErrorMessage(error);
|
|
const command = `printf '[%s] ❌ Error: %s\\n' "$(date)" ${quote([message])} >> ${quote([logPath])}`;
|
|
try {
|
|
await runHostCommand(serverId, command);
|
|
} catch (logError) {
|
|
logger.error(
|
|
{ error: getSafeErrorMessage(logError) },
|
|
"Failed to append backup worker error to deployment log",
|
|
);
|
|
}
|
|
};
|
|
|
|
const appendBackupMessage = async (
|
|
serverId: string | null | undefined,
|
|
logPath: string,
|
|
message: string,
|
|
) => {
|
|
const command = `printf '[%s] %s\n' "$(date)" ${quote([message])} >> ${quote([logPath])}`;
|
|
try {
|
|
await runHostCommand(serverId, command);
|
|
} catch (logError) {
|
|
logger.error(
|
|
{ error: getSafeErrorMessage(logError) },
|
|
"Failed to append backup worker progress to deployment log",
|
|
);
|
|
}
|
|
};
|
|
|
|
const collectServiceLogs = (
|
|
serverId: string | null | undefined,
|
|
serviceName: string,
|
|
logPath: string,
|
|
) => {
|
|
const quotedServiceName = quote([serviceName]);
|
|
return runHostCommand(
|
|
serverId,
|
|
`if docker service inspect ${quotedServiceName} >/dev/null 2>&1; then docker service logs --raw ${quotedServiceName} >> ${quote([logPath])} 2>&1; fi`,
|
|
);
|
|
};
|
|
|
|
const removeResource = async (
|
|
remove: () => Promise<unknown>,
|
|
resource: "secret" | "service",
|
|
) => {
|
|
try {
|
|
await remove();
|
|
return null;
|
|
} catch (error) {
|
|
if (isNotFoundError(error)) {
|
|
return null;
|
|
}
|
|
return new Error(
|
|
`Failed to remove backup worker ${resource}: ${getSafeErrorMessage(error)}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const isDatabaseTaskRelocationError = (error: unknown) =>
|
|
error instanceof BackupWorkerTaskError &&
|
|
error.exitCode === BACKUP_WORKER_CONTAINER_NOT_FOUND_EXIT_CODE;
|
|
|
|
const isRetryableWorkerHandoffError = (error: unknown) =>
|
|
isDatabaseTaskRelocationError(error) ||
|
|
error instanceof BackupWorkerPreStartError;
|
|
|
|
const isDirectDatabaseTaskRelocationError = (error: unknown) =>
|
|
error instanceof ExecError &&
|
|
error.exitCode === BACKUP_WORKER_CONTAINER_NOT_FOUND_EXIT_CODE;
|
|
|
|
const runBackupOnWorkerAttempt = async ({
|
|
input: {
|
|
backup,
|
|
executionId,
|
|
logPath,
|
|
rcloneDestination,
|
|
rcloneFlags,
|
|
serverId,
|
|
},
|
|
attempt,
|
|
databaseTask: { containerId, nodeId },
|
|
docker,
|
|
}: {
|
|
input: ExecuteBackupInput;
|
|
attempt: number;
|
|
databaseTask: { containerId: string; nodeId: string };
|
|
docker: DockerClient;
|
|
}) => {
|
|
const { secretName, serviceName } = getBackupResourceNames(
|
|
executionId,
|
|
attempt,
|
|
);
|
|
const labels = {
|
|
"dokploy.backup.id": backup.backupId,
|
|
"dokploy.deployment.id": executionId,
|
|
"dokploy.managed": "true",
|
|
"dokploy.resource": "database-backup-worker",
|
|
};
|
|
// Keep database and S3 credentials out of the inspectable service arguments.
|
|
const workerCommand = getBackupCommand(
|
|
backup,
|
|
rcloneFlags,
|
|
rcloneDestination,
|
|
"/proc/1/fd/1",
|
|
{
|
|
containerId,
|
|
containerNotFoundExitCode: BACKUP_WORKER_CONTAINER_NOT_FOUND_EXIT_CODE,
|
|
},
|
|
);
|
|
const service = docker.getService(serviceName);
|
|
const secret = docker.getSecret(secretName);
|
|
|
|
let serviceMayExist = false;
|
|
let secretMayExist = false;
|
|
let primaryError: unknown;
|
|
|
|
try {
|
|
await appendBackupMessage(
|
|
serverId,
|
|
logPath,
|
|
`Preparing backup worker attempt ${attempt + 1} of ${MAX_BACKUP_WORKER_ATTEMPTS} on node ${nodeId}...`,
|
|
);
|
|
|
|
secretMayExist = true;
|
|
const createdSecret = (await docker.createSecret({
|
|
Name: secretName,
|
|
Labels: labels,
|
|
Data: Buffer.from(workerCommand).toString("base64"),
|
|
})) as { id?: string };
|
|
const secretId = createdSecret.id;
|
|
if (!secretId) {
|
|
throw new Error("Docker did not return a backup worker secret ID");
|
|
}
|
|
|
|
serviceMayExist = true;
|
|
const createdService = await docker.createService(
|
|
getBackupWorkerServiceSpec({
|
|
backupId: backup.backupId,
|
|
executionId,
|
|
nodeId,
|
|
secretId,
|
|
secretName,
|
|
serviceName,
|
|
}),
|
|
);
|
|
if (!createdService.id) {
|
|
throw new Error("Docker did not return a backup worker service ID");
|
|
}
|
|
await waitForBackupWorkerTask(docker, createdService.id);
|
|
} catch (error) {
|
|
primaryError = error;
|
|
} finally {
|
|
const cleanupErrors: Error[] = [];
|
|
|
|
if (serviceMayExist) {
|
|
// Collect once after the terminal task state to avoid follower races or duplicates.
|
|
if (!isRetryableWorkerHandoffError(primaryError)) {
|
|
try {
|
|
await collectServiceLogs(serverId, serviceName, logPath);
|
|
} catch (error) {
|
|
const message = getSafeErrorMessage(error);
|
|
logger.error(
|
|
{ error: message },
|
|
"Failed to collect backup worker logs",
|
|
);
|
|
await appendBackupMessage(
|
|
serverId,
|
|
logPath,
|
|
`⚠️ Warning: Could not collect backup worker logs: ${message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const error = await removeResource(() => service.remove(), "service");
|
|
if (error) cleanupErrors.push(error);
|
|
}
|
|
if (secretMayExist) {
|
|
const error = await removeResource(() => secret.remove(), "secret");
|
|
if (error) cleanupErrors.push(error);
|
|
}
|
|
|
|
if (primaryError) {
|
|
if (cleanupErrors.length > 0) {
|
|
logger.error(
|
|
{ errors: cleanupErrors.map((error) => error.message) },
|
|
"Backup worker cleanup also failed",
|
|
);
|
|
if (isRetryableWorkerHandoffError(primaryError)) {
|
|
primaryError = new Error(
|
|
`${getSafeErrorMessage(primaryError)}; ${cleanupErrors.map((error) => error.message).join("; ")}`,
|
|
);
|
|
}
|
|
}
|
|
} else if (cleanupErrors.length > 0) {
|
|
primaryError = new Error(
|
|
cleanupErrors.map((error) => error.message).join("; "),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (primaryError) {
|
|
throw primaryError;
|
|
}
|
|
};
|
|
|
|
const runBackupOnWorker = async (
|
|
input: ExecuteBackupInput,
|
|
previousContainerId?: string,
|
|
) => {
|
|
const serviceTarget = getBackupTargetServiceName(input.backup);
|
|
if (!serviceTarget) {
|
|
throw new Error("Could not determine the Swarm service for this backup");
|
|
}
|
|
|
|
const docker = await getRemoteDocker(input.serverId);
|
|
let databaseTask = previousContainerId
|
|
? await waitForReplacementServiceTask(
|
|
docker,
|
|
serviceTarget,
|
|
previousContainerId,
|
|
)
|
|
: await findRunningServiceTask(docker, serviceTarget);
|
|
for (let attempt = 0; attempt < MAX_BACKUP_WORKER_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
await runBackupOnWorkerAttempt({
|
|
attempt,
|
|
databaseTask,
|
|
docker,
|
|
input,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
const isRelocationError = isDatabaseTaskRelocationError(error);
|
|
const isPreStartError = error instanceof BackupWorkerPreStartError;
|
|
if (!isRelocationError && !isPreStartError) {
|
|
throw error;
|
|
}
|
|
if (attempt === MAX_BACKUP_WORKER_ATTEMPTS - 1) {
|
|
if (isPreStartError) {
|
|
throw error;
|
|
}
|
|
throw new Error(
|
|
`Database task moved while the backup worker was starting; retry limit reached (${getSafeErrorMessage(error)})`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
if (isPreStartError) {
|
|
let currentDatabaseTask: Awaited<
|
|
ReturnType<typeof findRunningServiceTask>
|
|
> | null = null;
|
|
try {
|
|
currentDatabaseTask = await findRunningServiceTask(
|
|
docker,
|
|
serviceTarget,
|
|
);
|
|
} catch (discoveryError) {
|
|
if (!(discoveryError instanceof RunningServiceTaskNotFoundError)) {
|
|
throw discoveryError;
|
|
}
|
|
}
|
|
|
|
// A rejected helper is not evidence of relocation by itself. Fail fast
|
|
// when the database is still on the node selected for this attempt.
|
|
if (currentDatabaseTask?.containerId === databaseTask.containerId) {
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
databaseTask =
|
|
currentDatabaseTask ??
|
|
(await waitForReplacementServiceTask(
|
|
docker,
|
|
serviceTarget,
|
|
databaseTask.containerId,
|
|
));
|
|
} catch (replacementError) {
|
|
if (replacementError instanceof ReplacementServiceTaskNotFoundError) {
|
|
throw error;
|
|
}
|
|
throw replacementError;
|
|
}
|
|
await appendBackupMessage(
|
|
input.serverId,
|
|
input.logPath,
|
|
"Database task moved while the backup worker was waiting to start; retrying on its new node...",
|
|
);
|
|
} else {
|
|
await appendBackupMessage(
|
|
input.serverId,
|
|
input.logPath,
|
|
"Database task moved before the backup started; rediscovering its node and retrying...",
|
|
);
|
|
databaseTask = await waitForReplacementServiceTask(
|
|
docker,
|
|
serviceTarget,
|
|
databaseTask.containerId,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const executeBackup = async (input: ExecuteBackupInput) => {
|
|
try {
|
|
let previousContainerId: string | undefined;
|
|
const containerSearch = getContainerSearchCommand(input.backup);
|
|
if (!containerSearch) {
|
|
throw new Error("Could not build the database container search command");
|
|
}
|
|
|
|
const { stdout } = await runHostCommand(input.serverId, containerSearch);
|
|
const directContainerId = stdout.trim().split(/\s+/)[0];
|
|
if (directContainerId) {
|
|
const command = getBackupCommand(
|
|
input.backup,
|
|
input.rcloneFlags,
|
|
input.rcloneDestination,
|
|
input.logPath,
|
|
{
|
|
containerId: directContainerId,
|
|
containerNotFoundExitCode:
|
|
BACKUP_WORKER_CONTAINER_NOT_FOUND_EXIT_CODE,
|
|
},
|
|
);
|
|
try {
|
|
await runHostCommand(input.serverId, command);
|
|
return { mode: "direct" as const };
|
|
} catch (error) {
|
|
if (!isDirectDatabaseTaskRelocationError(error)) {
|
|
throw error;
|
|
}
|
|
await appendBackupMessage(
|
|
input.serverId,
|
|
input.logPath,
|
|
"Database task moved off this node before the backup started; switching to a backup worker...",
|
|
);
|
|
previousContainerId = directContainerId;
|
|
}
|
|
}
|
|
|
|
await runBackupOnWorker(input, previousContainerId);
|
|
return { mode: "swarm-worker" as const };
|
|
} catch (error) {
|
|
// ExecError retains the full shell command, which contains backup credentials.
|
|
// Its output may echo that command too, so only expose non-secret metadata.
|
|
const publicError =
|
|
error instanceof ExecError
|
|
? new Error(getPublicExecErrorMessage(error))
|
|
: error;
|
|
await appendBackupError(input.serverId, input.logPath, publicError);
|
|
throw publicError;
|
|
}
|
|
};
|