Merge branch 'canary' into feat/middleware-configuration

This commit is contained in:
Mauricio Siu
2026-04-04 09:39:21 -06:00
665 changed files with 303868 additions and 37981 deletions

View File

@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import {
getWebServerSettings,
@@ -12,8 +14,6 @@ export const startLogCleanup = async (
cronExpression = "0 0 * * *",
): Promise<boolean> => {
try {
const { DYNAMIC_TRAEFIK_PATH } = paths();
const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
@@ -21,10 +21,17 @@ export const startLogCleanup = async (
scheduleJob(LOG_CLEANUP_JOB_NAME, cronExpression, async () => {
try {
await execAsync(
`tail -n 1000 ${DYNAMIC_TRAEFIK_PATH}/access.log > ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp && mv ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp ${DYNAMIC_TRAEFIK_PATH}/access.log`,
);
const { DYNAMIC_TRAEFIK_PATH } = paths();
const accessLogPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (!fs.existsSync(accessLogPath)) {
console.error("Access log file does not exist");
return;
}
await execAsync(
`tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
);
await execAsync("docker exec dokploy-traefik kill -USR1 1");
} catch (error) {
console.error("Error during log cleanup:", error);

View File

@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl,
});
case "azure":
// Azure OpenAI-compatible endpoints already include /v1 in the path.
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
if (config.apiUrl.includes("/v1")) {
return createOpenAICompatible({
name: "azure",
baseURL: config.apiUrl,
headers: {
"api-key": config.apiKey,
Authorization: `Bearer ${config.apiKey}`,
},
});
}
return createAzure({
apiKey: config.apiKey,
baseURL: config.apiUrl,
@@ -71,8 +83,9 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
return createOpenAICompatible({
name: "gemini",
baseURL: config.apiUrl,
queryParams: { key: config.apiKey },
headers: {},
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "custom":
return createOpenAICompatible({
@@ -102,7 +115,7 @@ export const getProviderHeaders = (
// Mistral
if (apiUrl.includes("mistral")) {
return {
Authorization: apiKey,
Authorization: `Bearer ${apiKey}`,
};
}

View File

@@ -8,19 +8,25 @@ import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { environmentId, name } = compose;
const { environmentId, name, appName } = compose;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType } = backup;
const { prefix, databaseType, serviceName } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Compose Backup",

View File

@@ -1,4 +1,4 @@
import path from "node:path";
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { getAllServers } from "@dokploy/server/services/server";
@@ -10,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
import { cleanupAll } from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, scheduleBackup } from "./utils";
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
@@ -29,15 +29,19 @@ export const initCronJobs = async () => {
const webServerSettings = await getWebServerSettings();
if (webServerSettings?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
);
try {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
);
await cleanupAll();
await cleanupAll();
await sendDockerCleanupNotifications(admin.user.id);
});
await sendDockerCleanupNotifications(admin.user.id);
});
} catch (error) {
console.error("[Backup] Docker Cleanup Error", error);
}
}
const servers = await getAllServers();
@@ -45,18 +49,22 @@ export const initCronJobs = async () => {
for (const server of servers) {
const { serverId, enableDockerCleanup, name } = server;
if (enableDockerCleanup) {
scheduleJob(serverId, "0 0 * * *", async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);
try {
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);
await cleanupAll(serverId);
await cleanupAll(serverId);
await sendDockerCleanupNotifications(
admin.user.id,
`Docker cleanup for Server ${name} (${serverId})`,
);
});
await sendDockerCleanupNotifications(
admin.user.id,
`Docker cleanup for Server ${name} (${serverId})`,
);
});
} catch (error) {
console.error(`[Backup] ${error}`);
}
}
}
@@ -67,6 +75,7 @@ export const initCronJobs = async () => {
mariadb: true,
mysql: true,
mongo: true,
libsql: true,
user: true,
compose: true,
},
@@ -86,14 +95,33 @@ export const initCronJobs = async () => {
}
if (webServerSettings?.logCleanupCron) {
console.log(
"Starting log requests cleanup",
webServerSettings.logCleanupCron,
);
await startLogCleanup(webServerSettings.logCleanupCron);
try {
console.log(
"Starting log requests cleanup",
webServerSettings.logCleanupCron,
);
await startLogCleanup(webServerSettings.logCleanupCron);
} catch (error) {
console.error("[Backup] Log Cleanup Error", error);
}
}
};
const getServiceAppName = (backup: BackupSchedule): string => {
if (backup.compose?.appName) {
return backup.serviceName
? `${backup.compose.appName}_${backup.serviceName}`
: backup.compose.appName;
}
const serviceAppName =
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName ||
backup.libsql?.appName;
return serviceAppName || backup.appName;
};
export const keepLatestNBackups = async (
backup: BackupSchedule,
serverId?: string | null,
@@ -104,18 +132,16 @@ export const keepLatestNBackups = async (
try {
const rcloneFlags = getS3Credentials(backup.destination);
const backupFilesPath = path.join(
`:s3:${backup.destination.bucket}`,
backup.prefix,
);
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
// this command deletes the files
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`;
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;

View File

@@ -0,0 +1,80 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runLibsqlBackup = async (
libsql: Libsql,
backup: BackupSchedule,
) => {
const { name, environmentId, appName } = libsql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Initializing Backup",
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (libsql.serverId) {
await execAsyncRemote(libsql.serverId, backupCommand);
} else {
await execAsync(backupCommand, {
shell: "/bin/bash",
});
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "success",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -8,19 +8,24 @@ import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMariadbBackup = async (
mariadb: Mariadb,
backup: BackupSchedule,
) => {
const { environmentId, name } = mariadb;
const { environmentId, name, appName } = mariadb;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MariaDB Backup",

View File

@@ -8,16 +8,21 @@ import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { environmentId, name } = mongo;
const { environmentId, name, appName } = mongo;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MongoDB Backup",

View File

@@ -8,16 +8,21 @@ import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { environmentId, name } = mysql;
const { environmentId, name, appName } = mysql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MySQL Backup",

View File

@@ -8,13 +8,18 @@ import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runPostgresBackup = async (
postgres: Postgres,
backup: BackupSchedule,
) => {
const { name, environmentId } = postgres;
const { name, environmentId, appName } = postgres;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
@@ -25,8 +30,8 @@ export const runPostgresBackup = async (
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -4,6 +4,7 @@ import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
@@ -19,6 +20,7 @@ export const scheduleBackup = (backup: BackupSchedule) => {
mysql,
mongo,
mariadb,
libsql,
compose,
} = backup;
scheduleJob(backupId, schedule, async () => {
@@ -35,6 +37,9 @@ export const scheduleBackup = (backup: BackupSchedule) => {
} 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);
@@ -51,6 +56,9 @@ export const removeScheduleBackup = (backupId: string) => {
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, "");
@@ -74,6 +82,10 @@ export const getS3Credentials = (destination: Destination) => {
rcloneFlags.unshift(`--s3-provider="${provider}"`);
}
if (destination.additionalFlags?.length) {
rcloneFlags.push(...destination.additionalFlags);
}
return rcloneFlags;
};
@@ -107,6 +119,10 @@ export const getMongoBackupCommand = (
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
};
export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
};
export const getServiceContainerCommand = (appName: string) => {
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
};
@@ -123,12 +139,24 @@ export const getComposeContainerCommand = (
};
const getContainerSearchCommand = (backup: BackupSchedule) => {
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
backup;
const {
backupType,
postgres,
mysql,
mariadb,
mongo,
libsql,
compose,
serviceName,
} = backup;
if (backupType === "database") {
const appName =
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName;
postgres?.appName ||
mysql?.appName ||
mariadb?.appName ||
mongo?.appName ||
libsql?.appName;
return getServiceContainerCommand(appName || "");
}
if (backupType === "compose") {
@@ -209,6 +237,12 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
}
break;
}
case "libsql": {
if (backupType === "database") {
return getLibsqlBackupCommand(backup.database);
}
break;
}
default:
throw new Error(`Database type not supported: ${databaseType}`);
}

View File

@@ -1,5 +1,5 @@
import { createWriteStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
@@ -9,8 +9,18 @@ import {
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
import { execAsync } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
function formatBytes(bytes?: number) {
if (bytes === undefined) return "Unknown size";
if (bytes === 0) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(2)} ${sizes[i]} (${bytes} bytes)`;
}
export const runWebServerBackup = async (backup: BackupSchedule) => {
if (IS_CLOUD) {
@@ -23,15 +33,15 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
description: "Web Server Backup",
});
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
let computedBackupSize: number | undefined;
try {
const destination = await findDestinationById(backup.destinationId);
const rcloneFlags = getS3Credentials(destination);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const timestamp = getBackupTimestamp();
const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`;
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
try {
await execAsync(`mkdir -p ${tempDir}/filesystem`);
@@ -67,7 +77,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
await execAsync(cleanupCommand);
await execAsync(
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");
@@ -79,11 +89,24 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
writeStream.write("Zipped database and filesystem\n");
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
const zipPath = join(tempDir, backupFileName);
try {
const { size } = await stat(zipPath);
computedBackupSize = size;
writeStream.write(`Backup size: ${size} bytes\n`);
} catch {
// If stat fails, keep undefined
}
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${zipPath}" "${s3Path}"`;
writeStream.write("Running command to upload backup to S3\n");
await execAsync(uploadCommand);
writeStream.write("Uploaded backup to S3 ✅\n");
writeStream.end();
await sendDokployBackupNotifications({
type: "success",
backupSize: formatBytes(computedBackupSize),
});
await updateDeploymentStatus(deployment.deploymentId, "done");
return true;
} finally {
@@ -100,6 +123,12 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
error instanceof Error ? error.message : "Unknown error\n",
);
writeStream.end();
await sendDokployBackupNotifications({
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
backupSize: formatBytes(computedBackupSize),
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}

View File

@@ -6,7 +6,7 @@ import { quote } from "shell-quote";
import { writeDomainsToCompose } from "../docker/domain";
import {
encodeBase64,
getEnviromentVariablesObject,
getEnvironmentVariablesObject,
prepareEnvironmentVariables,
} from "../docker/utils";
@@ -46,17 +46,17 @@ Compose Type: ${composeType} ✅`;
set -e
{
echo "${logBox}";
${newCompose}
${envCommand}
cd "${projectPath}";
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
echo "Docker Compose Deployed: ✅";
} || {
echo "Error: ❌ Script execution failed";
@@ -131,9 +131,10 @@ echo "${encodedContent}" | base64 -d > "${envFilePath}";
const getExportEnvCommand = (compose: ComposeNested) => {
if (compose.composeType !== "stack") return "";
const envVars = getEnviromentVariablesObject(
const envVars = getEnvironmentVariablesObject(
compose.env,
compose.environment.project.env,
compose.environment.env,
);
const exports = Object.entries(envVars)
.map(([key, value]) => `${key}=${quote([value])}`)

View File

@@ -1,5 +1,5 @@
import {
getEnviromentVariablesObject,
getEnvironmentVariablesObject,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/utils/docker/utils";
import { quote } from "shell-quote";
@@ -52,7 +52,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
commandArgs.push("--build-arg", arg);
}
const secrets = getEnviromentVariablesObject(
const secrets = getEnvironmentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
@@ -86,12 +86,12 @@ export const getDockerCommand = (application: ApplicationNested) => {
command += `
echo "Building ${appName}" ;
cd ${dockerContextPath} || {
cd ${dockerContextPath} || {
echo "❌ The path ${dockerContextPath} does not exist" ;
exit 1;
}
${joinedSecrets} docker ${commandArgs.join(" ")} || {
${joinedSecrets} docker ${commandArgs.join(" ")} || {
echo "❌ Docker build failed" ;
exit 1;
}

View File

@@ -3,6 +3,7 @@ import path, { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { Application } from "@dokploy/server/services/application";
import { findServerById } from "@dokploy/server/services/server";
import { readValidDirectory } from "@dokploy/server/wss/utils";
import AdmZip from "adm-zip";
import { Client, type SFTPWrapper } from "ssh2";
import {
@@ -16,10 +17,13 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
try {
const { appName } = application;
const { APPLICATIONS_PATH } = paths(!!application.serverId);
// Use buildServerId if set, otherwise fall back to serverId
// This ensures the code is extracted to the server where the build will run
const targetServerId = application.buildServerId || application.serverId;
const { APPLICATIONS_PATH } = paths(!!targetServerId);
const outputPath = join(APPLICATIONS_PATH, appName, "code");
if (application.serverId) {
await recreateDirectoryRemote(outputPath, application.serverId);
if (targetServerId) {
await recreateDirectoryRemote(outputPath, targetServerId);
} else {
await recreateDirectory(outputPath);
}
@@ -45,8 +49,8 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
? rootEntries[0]?.entryName.split("/")[0]
: "";
if (application.serverId) {
sftp = await getSFTPConnection(application.serverId);
if (targetServerId) {
sftp = await getSFTPConnection(targetServerId);
}
for (const entry of zipEntries) {
let filePath = entry.entryName;
@@ -62,16 +66,24 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
if (!filePath) continue;
const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/");
if (!readValidDirectory(fullPath, application.serverId)) {
throw new Error(
`Path traversal detected: resolved path escapes output directory: ${filePath}`,
);
}
if (application.serverId) {
if (isDangerousNode(entry)) {
throw new Error(
`Dangerous node entries are not allowed: ${entry.entryName}`,
);
}
if (targetServerId) {
if (!entry.isDirectory) {
if (sftp === null) throw new Error("No SFTP connection available");
try {
const dirPath = path.dirname(fullPath);
await execAsyncRemote(
application.serverId,
`mkdir -p "${dirPath}"`,
);
await execAsyncRemote(targetServerId, `mkdir -p "${dirPath}"`);
await uploadFileToServer(sftp, entry.getData(), fullPath);
} catch (err) {
console.error(`Error uploading file ${fullPath}:`, err);
@@ -132,3 +144,14 @@ const uploadFileToServer = (
});
});
};
function isDangerousNode(entry: AdmZip.IZipEntry) {
const type = (entry.header.attr >> 16) & 0o170000;
return (
type === 0o120000 || // symlink
type === 0o060000 || // block device
type === 0o020000 || // char device
type === 0o010000 // fifo/pipe
);
}

View File

@@ -110,6 +110,7 @@ export const mechanizeDockerContainer = async (
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(application);
const bindsMount = generateBindMounts(mounts);
@@ -142,7 +143,7 @@ export const mechanizeDockerContainer = async (
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,
@@ -181,7 +182,11 @@ export const mechanizeDockerContainer = async (
});
} catch (error) {
console.log(error);
await docker.createService(settings);
if (authConfig) {
await docker.createService(authConfig, settings);
} else {
await docker.createService(settings);
}
}
};

View File

@@ -1,25 +1,6 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { encodeBase64, prepareEnvironmentVariables } from "../docker/utils";
export const createEnvFile = (
directory: string,
env: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
) => {
const envFilePath = join(dirname(directory), ".env");
if (!existsSync(dirname(envFilePath))) {
mkdirSync(dirname(envFilePath), { recursive: true });
}
const envFileContent = prepareEnvironmentVariables(
env,
projectEnv,
environmentEnv,
).join("\n");
writeFileSync(envFilePath, envFileContent);
};
export const createEnvFileCommand = (
directory: string,
env: string | null,

View File

@@ -0,0 +1,68 @@
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
import { and, eq, isNotNull } from "drizzle-orm";
import { scheduleJob } from "node-schedule";
import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
// process.env.NODE_ENV === "development"
// ? "http://localhost:4002"
"https://licenses-api.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
const users = await db.query.user.findMany({
where: and(
isNotNull(userSchema.licenseKey),
isNotNull(userSchema.enableEnterpriseFeatures),
eq(userSchema.isValidEnterpriseLicense, true),
),
});
for (const user of users) {
if (user.isValidEnterpriseLicense) {
console.log(
"Validating license key....",
user.firstName,
user.lastName,
);
try {
const isValid = await validateLicenseKey(user.licenseKey || "");
if (!isValid) {
throw new Error("License key is invalid");
}
} catch (error) {
await db
.update(userSchema)
.set({ isValidEnterpriseLicense: false })
.where(eq(userSchema.id, user.id));
}
}
}
});
};
export const validateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
const result = await fetch(`${LICENSE_KEY_URL}/licenses/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, ip }),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to validate license key");
}
const data = await result.json();
return data.valid;
} catch (error) {
console.error(
error instanceof Error ? error.message : "Failed to validate license key",
);
throw error;
}
};

View File

@@ -0,0 +1,155 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import {
calculateResources,
generateBindMounts,
generateConfigContainer,
generateFileMounts,
generateVolumeMounts,
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
export type LibsqlNested = InferResultType<
"libsql",
{
mounts: true;
environment: { with: { project: true } };
}
>;
export const buildLibsql = async (libsql: LibsqlNested) => {
const {
appName,
env,
externalPort,
externalGRPCPort,
externalAdminPort,
memoryLimit,
memoryReservation,
databaseUser,
databasePassword,
sqldNode,
sqldPrimaryUrl,
cpuLimit,
cpuReservation,
command,
mounts,
enableNamespaces,
} = libsql;
const basicAuth = Buffer.from(
`${databaseUser}:${databasePassword}`,
"utf-8",
).toString("base64");
const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
env ? `\n${env}` : ""
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
const {
HealthCheck,
RestartPolicy,
Placement,
Labels,
Mode,
RollbackConfig,
UpdateConfig,
Networks,
} = generateConfigContainer(libsql);
const resources = calculateResources({
memoryLimit,
memoryReservation,
cpuLimit,
cpuReservation,
});
const envVariables = prepareEnvironmentVariables(
defaultLibsqlEnv,
libsql.environment.project.env,
libsql.environment.env,
);
const volumesMount = generateVolumeMounts(mounts);
const bindsMount = generateBindMounts(mounts);
const filesMount = generateFileMounts(appName, libsql);
const docker = await getRemoteDocker(libsql.serverId);
let finalCommand =
command ??
"sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000";
if (enableNamespaces) {
finalCommand += " --enable-namespaces";
}
const settings: CreateServiceOptions = {
Name: appName,
TaskTemplate: {
ContainerSpec: {
HealthCheck,
Image: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(finalCommand
? {
Command: ["/bin/sh"],
Args: ["-c", finalCommand],
}
: {}),
Labels,
},
Networks,
RestartPolicy,
Placement,
Resources: {
...resources,
},
},
Mode,
RollbackConfig,
EndpointSpec: {
Mode: "dnsrr",
Ports: [
...(externalPort
? [
{
Protocol: "tcp",
TargetPort: 8080,
PublishedPort: externalPort,
PublishMode: "host",
} as PortConfig,
]
: []),
...(externalGRPCPort
? [
{
Protocol: "tcp",
TargetPort: 5001,
PublishedPort: externalGRPCPort,
PublishMode: "host",
} as PortConfig,
]
: []),
...(externalAdminPort
? [
{
Protocol: "tcp",
TargetPort: 5000,
PublishedPort: externalAdminPort,
PublishMode: "host",
} as PortConfig,
]
: []),
],
},
UpdateConfig,
};
try {
const service = docker.getService(appName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
});
} catch {
await docker.createService(settings);
}
};

View File

@@ -48,6 +48,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mariadb);
const resources = calculateResources({
memoryLimit,
@@ -83,7 +84,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -94,6 +94,7 @@ ${command ?? "wait $MONGOD_PID"}`;
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mongo);
const resources = calculateResources({
@@ -139,7 +140,7 @@ ${command ?? "wait $MONGOD_PID"}`;
!replicaSets && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -54,6 +54,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(mysql);
const resources = calculateResources({
memoryLimit,
@@ -89,7 +90,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -47,6 +47,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(postgres);
const resources = calculateResources({
memoryLimit,
@@ -82,7 +83,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
args.length > 0 && {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -1,11 +1,13 @@
import { db } from "@dokploy/server/db";
import {
libsql,
mariadb,
mongo,
mysql,
postgres,
redis,
} from "@dokploy/server/db/schema";
import { deployLibsql } from "@dokploy/server/services/libsql";
import { deployMariadb } from "@dokploy/server/services/mariadb";
import { deployMongo } from "@dokploy/server/services/mongo";
import { deployMySql } from "@dokploy/server/services/mysql";
@@ -15,7 +17,13 @@ import { eq } from "drizzle-orm";
import { removeService } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
type DatabaseType = "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
type DatabaseType =
| "libsql"
| "mariadb"
| "mongo"
| "mysql"
| "postgres"
| "redis";
export const rebuildDatabase = async (
databaseId: string,
@@ -41,31 +49,25 @@ export const rebuildDatabase = async (
}
}
if (type === "postgres") {
await deployPostgres(databaseId);
} else if (type === "mysql") {
await deployMySql(databaseId);
if (type === "libsql") {
await deployLibsql(databaseId);
} else if (type === "mariadb") {
await deployMariadb(databaseId);
} else if (type === "mongo") {
await deployMongo(databaseId);
} else if (type === "mysql") {
await deployMySql(databaseId);
} else if (type === "postgres") {
await deployPostgres(databaseId);
} else if (type === "redis") {
await deployRedis(databaseId);
}
};
const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
if (type === "postgres") {
return await db.query.postgres.findFirst({
where: eq(postgres.postgresId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "mysql") {
return await db.query.mysql.findFirst({
where: eq(mysql.mysqlId, databaseId),
if (type === "libsql") {
return await db.query.libsql.findFirst({
where: eq(libsql.libsqlId, databaseId),
with: {
mounts: true,
},
@@ -87,6 +89,22 @@ const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
},
});
}
if (type === "mysql") {
return await db.query.mysql.findFirst({
where: eq(mysql.mysqlId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "postgres") {
return await db.query.postgres.findFirst({
where: eq(postgres.postgresId, databaseId),
with: {
mounts: true,
},
});
}
if (type === "redis") {
return await db.query.redis.findFirst({
where: eq(redis.redisId, databaseId),

View File

@@ -45,6 +45,7 @@ export const buildRedis = async (redis: RedisNested) => {
Networks,
StopGracePeriod,
EndpointSpec,
Ulimits,
} = generateConfigContainer(redis);
const resources = calculateResources({
memoryLimit,
@@ -87,6 +88,7 @@ export const buildRedis = async (redis: RedisNested) => {
Command: ["/bin/sh"],
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
...(Ulimits && { Ulimits }),
Labels,
},
Networks,

View File

@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
) => {
const compose = await findComposeById(composeId);
const composeFile = compose.composeFile;
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = parse(composeFile, {
maxAliasCount: 10000,
}) as ComposeSpecification;
const randomSuffix = suffix || generateRandomHash();

View File

@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8");
const parsedConfig = parse(yamlStr) as ComposeSpecification;
const parsedConfig = parse(yamlStr, {
maxAliasCount: 10000,
}) as ComposeSpecification;
return parsedConfig;
}
return null;
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
return null;
}
if (!stdout) return null;
const parsedConfig = parse(stdout) as ComposeSpecification;
const parsedConfig = parse(stdout, {
maxAliasCount: 10000,
}) as ComposeSpecification;
return parsedConfig;
} catch {
return null;
@@ -106,10 +110,6 @@ export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
if (!domains.length) {
return "";
}
try {
const composeConverted = await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
@@ -145,7 +145,7 @@ export const addDomainToCompose = async (
result = await loadDockerCompose(compose);
}
if (!result || domains.length === 0) {
if (!result) {
return null;
}
@@ -164,14 +164,20 @@ export const addDomainToCompose = async (
for (const domain of domains) {
const { serviceName, https } = domain;
if (!serviceName) {
throw new Error("Service name not found");
throw new Error(`Domain "${domain.host}" is missing a service name`);
}
if (!result?.services?.[serviceName]) {
throw new Error(`The service ${serviceName} not found in the compose`);
throw new Error(
`Domain "${domain.host}" is attached to service "${serviceName}" which does not exist in the compose`,
);
}
const httpLabels = createDomainLabels(appName, domain, "web");
if (https) {
const httpLabels = createDomainLabels(
appName,
domain,
domain.customEntrypoint || "web",
);
if (!domain.customEntrypoint && https) {
const httpsLabels = createDomainLabels(appName, domain, "websecure");
httpLabels.push(...httpsLabels);
}
@@ -249,11 +255,12 @@ export const writeComposeFile = async (
export const createDomainLabels = (
appName: string,
domain: Domain,
entrypoint: "web" | "websecure",
entrypoint: string,
) => {
const {
host,
port,
customEntrypoint,
https,
uniqueConfigKey,
certificateType,
@@ -282,7 +289,7 @@ export const createDomainLabels = (
if (stripPath && path && path !== "/") {
const middlewareName = `stripprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint)
if (entrypoint === "web") {
if (entrypoint === "web" || customEntrypoint) {
labels.push(
`traefik.http.middlewares.${middlewareName}.stripprefix.prefixes=${path}`,
);
@@ -294,7 +301,7 @@ export const createDomainLabels = (
if (internalPath && internalPath !== "/" && internalPath.startsWith("/")) {
const middlewareName = `addprefix-${appName}-${uniqueConfigKey}`;
// Only define middleware once (on web entrypoint)
if (entrypoint === "web") {
if (entrypoint === "web" || customEntrypoint) {
labels.push(
`traefik.http.middlewares.${middlewareName}.addprefix.prefix=${internalPath}`,
);
@@ -315,7 +322,7 @@ export const createDomainLabels = (
}
// Add TLS configuration for websecure
if (entrypoint === "websecure") {
if (entrypoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") {
labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
@@ -335,6 +342,7 @@ export const addDokployNetworkToService = (
) => {
let networks = networkService;
const network = "dokploy-network";
const defaultNetwork = "default";
if (!networks) {
networks = [];
}
@@ -343,10 +351,16 @@ export const addDokployNetworkToService = (
if (!networks.includes(network)) {
networks.push(network);
}
if (!networks.includes(defaultNetwork)) {
networks.push(defaultNetwork);
}
} else if (networks && typeof networks === "object") {
if (!(network in networks)) {
networks[network] = {};
}
if (!(defaultNetwork in networks)) {
networks[defaultNetwork] = {};
}
}
return networks;

View File

@@ -7,6 +7,7 @@ import type { ContainerInfo, ResourceRequirements } from "dockerode";
import { parse } from "dotenv";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
import type { LibsqlNested } from "../databases/libsql";
import type { MariadbNested } from "../databases/mariadb";
import type { MongoNested } from "../databases/mongo";
import type { MysqlNested } from "../databases/mysql";
@@ -258,6 +259,48 @@ export const cleanupSystem = async (serverId?: string) => {
}
};
export interface DockerDiskUsageItem {
type: string;
totalCount: number;
active: number;
size: string;
reclaimable: string;
sizeBytes: number;
}
const parseSizeToBytes = (size: string): number => {
const match = size.match(/^([\d.]+)\s*([KMGT]?B)$/i);
if (!match) return 0;
const value = Number.parseFloat(match[1] as string);
const unit = (match[2] as string).toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
return value * (multipliers[unit] || 0);
};
export const getDockerDiskUsage = async (): Promise<DockerDiskUsageItem[]> => {
const command = "docker system df --format '{{json .}}'";
const { stdout } = await execAsync(command);
const lines = stdout.trim().split("\n").filter(Boolean);
return lines.map((line) => {
const data = JSON.parse(line);
return {
type: data.Type,
totalCount: Number.parseInt(data.TotalCount, 10) || 0,
active: Number.parseInt(data.Active, 10) || 0,
size: data.Size,
reclaimable: data.Reclaimable,
sizeBytes: parseSizeToBytes(data.Size),
};
});
};
/**
* Volume cleanup should always be performed manually by the user. The reason is that during automatic cleanup, a volume may be deleted due to a stopped container, which is a dangerous situation.
*
@@ -433,7 +476,7 @@ export const parseEnvironmentKeyValuePair = (
return [key, valueParts.join("=")];
};
export const getEnviromentVariablesObject = (
export const getEnvironmentVariablesObject = (
input: string | null,
projectEnv?: string | null,
environmentEnv?: string | null,
@@ -508,6 +551,7 @@ export const generateConfigContainer = (
networkSwarm,
stopGracePeriodSwarm,
endpointSpecSwarm,
ulimitsSwarm,
} = application;
const sanitizedStopGracePeriodSwarm =
@@ -584,6 +628,10 @@ export const generateConfigContainer = (
})) || [],
},
}),
...(ulimitsSwarm &&
ulimitsSwarm.length > 0 && {
Ulimits: ulimitsSwarm,
}),
};
};
@@ -605,6 +653,7 @@ export const generateFileMounts = (
appName: string,
service:
| ApplicationNested
| LibsqlNested
| MongoNested
| MariadbNested
| MysqlNested
@@ -736,3 +785,177 @@ export const getComposeContainer = async (
throw error;
}
};
type ServiceHealthStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
const checkSwarmServiceRunning = async (
serviceName: string,
): Promise<ServiceHealthStatus> => {
try {
const service = docker.getService(serviceName);
const info = await service.inspect();
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
if (replicas === 0) {
return {
status: "unhealthy",
message: "Service has 0 replicas configured",
};
}
// Check that at least one task is actually running
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
if (!runningTask) {
const latestTask = tasks[0];
const taskState = latestTask?.Status?.State ?? "unknown";
return {
status: "unhealthy",
message: `No running tasks (current state: ${taskState})`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Service not found",
};
}
};
const getSwarmServiceContainerId = async (
serviceName: string,
): Promise<string | null> => {
try {
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
} catch {
return null;
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify PostgreSQL actually accepts connections
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["pg_isready", "-U", "dokploy"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
const inspectResult = await exec.inspect();
if (inspectResult.ExitCode !== 0) {
return {
status: "unhealthy",
message: `PostgreSQL not ready: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message:
error instanceof Error ? error.message : "Failed to check PostgreSQL",
};
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
const container = docker.getContainer("dokploy-traefik");
const info = await container.inspect();
if (!info.State.Running) {
return {
status: "unhealthy",
message: "Container is not running",
};
}
return { status: "healthy" };
} catch {
// Not a standalone container, check as swarm service
return checkSwarmServiceRunning("dokploy-traefik");
}
};

View File

@@ -102,7 +102,8 @@ export const removeMonitoringDirectory = async (
};
export const getBuildAppDirectory = (application: Application) => {
const { APPLICATIONS_PATH } = paths(!!application.serverId);
const serverId = application.buildServerId || application.serverId;
const { APPLICATIONS_PATH } = paths(!!serverId);
const { appName, buildType, sourceType, customGitBuildPath, dockerfile } =
application;
let buildPath = "";
@@ -126,7 +127,7 @@ export const getBuildAppDirectory = (application: Application) => {
appName,
"code",
buildPath ?? "",
dockerfile || "",
dockerfile || "Dockerfile",
);
}

View File

@@ -10,8 +10,12 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -44,18 +48,34 @@ export const sendBuildErrorNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildFailedEmail({
projectName,
@@ -66,11 +86,22 @@ export const sendBuildErrorNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build failed for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build failed for dokploy",
template,
);
}
}
if (discord) {
@@ -222,6 +253,26 @@ export const sendBuildErrorNotifications = async ({
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `:warning: **Build Failed**
**Project:** ${projectName}
**Application:** ${applicationName}
**Type:** ${applicationType}
**Time:** ${date.toLocaleString()}
**Error:**
\`\`\`
${errorMessage}
\`\`\`
[View Build Details](${buildLink})`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy Bot",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Build Error",
@@ -349,6 +400,34 @@ export const sendBuildErrorNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Build Failed",
`Project: ${projectName}\nApplication: ${applicationName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}\nError: ${errorMessage}`,
);
}
if (teams) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendTeamsNotification(teams, {
title: "⚠️ Build Failed",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
{ name: "Error Message", value: truncatedErrorMessage },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -11,8 +11,12 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -47,18 +51,34 @@ export const sendBuildSuccessNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
BuildSuccessEmail({
projectName,
@@ -69,11 +89,22 @@ export const sendBuildSuccessNotifications = async ({
environmentName,
}),
).catch();
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Build success for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Build success for dokploy",
template,
);
}
}
if (discord) {
@@ -238,6 +269,14 @@ export const sendBuildSuccessNotifications = async ({
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Build Success**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${applicationType}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}\n\n[View Build Details](${buildLink})`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Build Success",
@@ -363,6 +402,32 @@ export const sendBuildSuccessNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Build Success",
`Project: ${projectName}\nApplication: ${applicationName}\nEnvironment: ${environmentName}\nType: ${applicationType}\nDate: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Build Success",
facts: [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Environment", value: environmentName },
{ name: "Type", value: applicationType },
{ name: "Date", value: format(date, "PP pp") },
],
potentialAction: {
type: "Action.OpenUrl",
title: "View Build Details",
url: buildLink,
},
});
}
} catch (error) {
console.log(error);
}

View File

@@ -10,8 +10,12 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -26,7 +30,7 @@ export const sendDatabaseBackupNotifications = async ({
}: {
projectName: string;
applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success";
organizationId: string;
errorMessage?: string;
@@ -44,18 +48,34 @@ export const sendDatabaseBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DatabaseBackupEmail({
projectName,
@@ -66,11 +86,22 @@ export const sendDatabaseBackupNotifications = async ({
date: date.toLocaleString(),
}),
).catch();
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Database backup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Database backup for dokploy",
template,
);
}
}
if (discord) {
@@ -125,7 +156,7 @@ export const sendDatabaseBackupNotifications = async ({
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
value: `\`\`\`${errorMessage.length > 1010 ? `${errorMessage.substring(0, 1010)}...` : errorMessage}\`\`\``,
},
]
: []),
@@ -244,6 +275,21 @@ export const sendDatabaseBackupNotifications = async ({
});
}
if (mattermost) {
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg =
type === "error" && errorMessage
? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\``
: "";
await sendMattermostNotification(mattermost, {
text: `**${statusEmoji} Database Backup ${typeStatus}**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Type:** ${databaseType}\n**Database Name:** ${databaseName}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}${errorMsg}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Database Backup ${type === "success" ? "Successful" : "Failed"}`,
@@ -377,6 +423,38 @@ export const sendDatabaseBackupNotifications = async ({
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
`Project: ${projectName}\nApplication: ${applicationName}\nDatabase: ${databaseType}\nDatabase Name: ${databaseName}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Database Type", value: databaseType },
{ name: "Database Name", value: databaseName },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
facts,
});
}
} catch (error) {
console.log(error);
}

View File

@@ -10,8 +10,12 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -31,27 +35,53 @@ export const sendDockerCleanupNotifications = async (
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email) {
if (email || resend) {
const template = await renderAsync(
DockerCleanupEmail({ message, date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
if (email) {
await sendEmailNotification(
email,
"Docker cleanup for dokploy",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Docker cleanup for dokploy",
template,
);
}
}
if (discord) {
@@ -141,6 +171,14 @@ export const sendDockerCleanupNotifications = async (
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Docker Cleanup**\n\n**Message:** ${message}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: "Docker Cleanup",
@@ -230,6 +268,24 @@ export const sendDockerCleanupNotifications = async (
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Docker Cleanup",
`Date: ${date.toLocaleString()}\nMessage: ${message}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Docker Cleanup",
facts: [
{ name: "Date", value: format(date, "PP pp") },
{ name: "Message", value: message },
],
});
}
} catch (error) {
console.log(error);
}

View File

@@ -0,0 +1,416 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DokployBackupEmail from "@dokploy/server/emails/emails/dokploy-backup";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
export const sendDokployBackupNotifications = async ({
type,
errorMessage,
backupSize,
}: {
type: "error" | "success";
errorMessage?: string;
backupSize?: string;
}) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployBackup, true),
with: {
email: true,
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const {
email,
discord,
telegram,
slack,
resend,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email || resend) {
const template = await renderAsync(
DokployBackupEmail({
type,
errorMessage,
date: date.toLocaleString(),
backupSize,
}),
).catch();
if (email) {
await sendEmailNotification(
email,
"Dokploy instance backup",
template,
);
}
if (resend) {
await sendResendNotification(
resend,
"Dokploy instance backup",
template,
);
}
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Dokploy Backup Successful")
: decorate(">", "`❌` Dokploy Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`📦`", "Backup Type"),
value: "Complete Dokploy Instance",
inline: true,
},
...(backupSize
? [
{
name: decorate("`💾`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Status"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Instance Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("📦", "Backup Type: Complete Dokploy Instance")}` +
`${backupSize ? decorate("💾", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
"📦Backup Type: Complete Dokploy Instance\n" +
`${backupSize ? `💾Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
: "";
const messageText = `<b>${statusEmoji} Dokploy Backup ${typeStatus}</b>\n\n<b>Backup Type:</b> Complete Dokploy Instance${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Dokploy Backup Successful*"
: ":x: *Dokploy Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Backup Type",
value: "Complete Dokploy Instance",
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Dokploy Backup Successful"
: "❌ Dokploy Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content:
"**Backup Type:**\nComplete Dokploy Instance",
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
...(backupSize
? [
{
tag: "markdown",
content: `**Backup Size:**\n${backupSize}`,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
if (mattermost) {
const statusEmoji = type === "success" ? ":white_check_mark:" : ":x:";
const typeStatus = type === "success" ? "Successful" : "Failed";
await sendMattermostNotification(mattermost, {
text: `${statusEmoji} **Dokploy Backup ${typeStatus}**
**Backup Type:** Complete Dokploy Instance${backupSize ? `\n**Backup Size:** ${backupSize}` : ""}
**Date:** ${date.toLocaleString()}
**Status:** ${typeStatus}${type === "error" && errorMessage ? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\`` : ""}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy Bot",
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
message: `Dokploy instance backup ${type === "success" ? "completed successfully" : "failed"}`,
backupType: "Complete Dokploy Instance",
...(backupSize ? { backupSize } : {}),
...(type === "error" && errorMessage ? { errorMessage } : {}),
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: type,
type: "dokploy-backup",
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
`Backup Type: Complete Dokploy Instance${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `${type === "success" ? "✅" : "❌"} Dokploy Backup ${type === "success" ? "Successful" : "Failed"}`,
facts: [
{ name: "Backup Type", value: "Complete Dokploy Instance" },
...(backupSize ? [{ name: "Backup Size", value: backupSize }] : []),
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
...(type === "error" && errorMessage
? [{ name: "Error Message", value: errorMessage }]
: []),
],
});
}
} catch (error) {
console.error(error);
}
}
};

View File

@@ -10,217 +10,277 @@ import {
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
export const sendDokployRestartNotifications = async () => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployRestart, true),
with: {
email: true,
discord: true,
telegram: true,
slack: true,
gotify: true,
ntfy: true,
custom: true,
lark: true,
},
});
try {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployRestart, true),
with: {
email: true,
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification;
for (const notification of notificationList) {
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
try {
if (email) {
const template = await renderAsync(
DokployRestartEmail({ date: date.toLocaleString() }),
).catch();
try {
if (email || resend) {
const template = await renderAsync(
DokployRestartEmail({ date: date.toLocaleString() }),
).catch();
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
}
if (email) {
await sendEmailNotification(
email,
"Dokploy Server Restarted",
template,
);
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: decorate(">", "`✅` Dokploy Server Restarted"),
color: 0x57f287,
fields: [
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Restart Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("✅", "Dokploy Server Restarted"),
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Dokploy Server Restarted",
"white_check_mark",
"",
`🕒Date: ${date.toLocaleString()}`,
);
}
if (telegram) {
await sendTelegramNotification(
telegram,
`<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(
date,
"PP",
)}\n<b>Time:</b> ${format(date, "pp")}`,
);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#00FF00",
pretext: ":white_check_mark: *Dokploy Server Restarted*",
fields: [
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (custom) {
try {
await sendCustomNotification(custom, {
title: "Dokploy Server Restarted",
message: "Dokploy server has been restarted successfully",
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "success",
type: "dokploy-restart",
});
} catch (error) {
console.log(error);
if (resend) {
await sendResendNotification(
resend,
"Dokploy Server Restarted",
template,
);
}
}
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: decorate(">", "`✅` Dokploy Server Restarted"),
color: 0x57f287,
fields: [
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Restart Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("✅", "Dokploy Server Restarted"),
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Dokploy Server Restarted",
"white_check_mark",
"",
`🕒Date: ${date.toLocaleString()}`,
);
}
if (telegram) {
await sendTelegramNotification(
telegram,
`<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(
date,
"PP",
)}\n<b>Time:</b> ${format(date, "pp")}`,
);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#00FF00",
pretext: ":white_check_mark: *Dokploy Server Restarted*",
fields: [
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**✅ Dokploy Server Restarted**\n\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (custom) {
try {
await sendCustomNotification(custom, {
title: "Dokploy Server Restarted",
message: "Dokploy server has been restarted successfully",
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "success",
type: "dokploy-restart",
});
} catch (error) {
console.log(error);
}
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Dokploy Server Restarted",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: "**Status:**\nSuccessful",
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Restart Time:**\n${format(
date,
"PP pp",
)}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
header: {
title: {
tag: "plain_text",
content: "✅ Dokploy Server Restarted",
},
],
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: "**Status:**\nSuccessful",
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Restart Time:**\n${format(
date,
"PP pp",
)}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
},
});
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
"Dokploy Server Restarted",
`Date: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: "✅ Dokploy Server Restarted",
facts: [
{ name: "Status", value: "Successful" },
{ name: "Restart Time", value: format(date, "PP pp") },
],
});
}
} catch (error) {
console.log(error);
}
} catch (error) {
console.log(error);
}
} catch (error) {
console.error("[Dokploy] Restart notifications failed:", error);
}
};

View File

@@ -5,7 +5,10 @@ import {
sendCustomNotification,
sendDiscordNotification,
sendLarkNotification,
sendMattermostNotification,
sendPushoverNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -36,8 +39,11 @@ export const sendServerThresholdNotifications = async (
discord: true,
telegram: true,
slack: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
@@ -45,62 +51,72 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) {
const { discord, telegram, slack, custom, lark } = notification;
const {
discord,
telegram,
slack,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
try {
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: decorate(">", `\`⚠️\` Server ${payload.Type} Alert`),
color: typeColor,
fields: [
{
name: decorate("`🏷️`", "Server Name"),
value: payload.ServerName,
inline: true,
await sendDiscordNotification(discord, {
title: decorate(">", `\`⚠️\` Server ${payload.Type} Alert`),
color: typeColor,
fields: [
{
name: decorate("`🏷️`", "Server Name"),
value: payload.ServerName,
inline: true,
},
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate(typeEmoji, "Type"),
value: payload.Type,
inline: true,
},
{
name: decorate("📊", "Current Value"),
value: `${payload.Value.toFixed(2)}%`,
inline: true,
},
{
name: decorate("⚠️", "Threshold"),
value: `${payload.Threshold.toFixed(2)}%`,
inline: true,
},
{
name: decorate("`📜`", "Message"),
value: `\`\`\`${payload.Message}\`\`\``,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Server Monitoring Alert",
},
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate(typeEmoji, "Type"),
value: payload.Type,
inline: true,
},
{
name: decorate("📊", "Current Value"),
value: `${payload.Value.toFixed(2)}%`,
inline: true,
},
{
name: decorate("⚠️", "Threshold"),
value: `${payload.Threshold.toFixed(2)}%`,
inline: true,
},
{
name: decorate("`📜`", "Message"),
value: `\`\`\`${payload.Message}\`\`\``,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Server Monitoring Alert",
},
});
}
});
}
if (telegram) {
await sendTelegramNotification(
telegram,
`
if (telegram) {
await sendTelegramNotification(
telegram,
`
<b>⚠️ Server ${payload.Type} Alert</b>
<b>Server Name:</b> ${payload.ServerName}
<b>Type:</b> ${payload.Type}
@@ -109,161 +125,194 @@ export const sendServerThresholdNotifications = async (
<b>Message:</b> ${payload.Message}
<b>Time:</b> ${date.toLocaleString()}
`,
);
}
);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#FF0000",
pretext: `:warning: *Server ${payload.Type} Alert*`,
fields: [
{
title: "Server Name",
value: payload.ServerName,
short: true,
},
{
title: "Type",
value: payload.Type,
short: true,
},
{
title: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
short: true,
},
{
title: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
short: true,
},
{
title: "Message",
value: payload.Message,
},
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: "#FF0000",
pretext: `:warning: *Server ${payload.Type} Alert*`,
fields: [
{
title: "Server Name",
value: payload.ServerName,
short: true,
},
{
title: "Type",
value: payload.Type,
short: true,
},
{
title: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
short: true,
},
{
title: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
short: true,
},
{
title: "Message",
value: payload.Message,
},
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
],
},
],
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Server ${payload.Type} Alert`,
message: payload.Message,
serverName: payload.ServerName,
type: payload.Type,
currentValue: payload.Value,
threshold: payload.Threshold,
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "alert",
alertType: "server-threshold",
});
}
if (mattermost) {
await sendMattermostNotification(mattermost, {
text: `**⚠️ Server ${payload.Type} Alert**\n\n**Server Name:** ${payload.ServerName}\n**Type:** ${payload.Type}\n**Current Value:** ${payload.Value.toFixed(2)}%\n**Threshold:** ${payload.Threshold.toFixed(2)}%\n**Message:** ${payload.Message}\n**Time:** ${date.toLocaleString()}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
if (custom) {
await sendCustomNotification(custom, {
title: `Server ${payload.Type} Alert`,
message: payload.Message,
serverName: payload.ServerName,
type: payload.Type,
currentValue: payload.Value,
threshold: payload.Threshold,
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: "alert",
alertType: "server-threshold",
});
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
},
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
],
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Server ${payload.Type} Alert`,
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
} catch (error) {
console.log(error);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
}

View File

@@ -4,11 +4,16 @@ import type {
email,
gotify,
lark,
mattermost,
ntfy,
pushover,
resend,
slack,
teams,
telegram,
} from "@dokploy/server/db/schema";
import nodemailer from "nodemailer";
import { Resend } from "resend";
export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
@@ -45,6 +50,32 @@ export const sendEmailNotification = async (
}
};
export const sendResendNotification = async (
connection: typeof resend.$inferInsert,
subject: string,
htmlContent: string,
) => {
try {
const client = new Resend(connection.apiKey);
const result = await client.emails.send({
from: connection.fromAddress,
to: connection.toAddresses,
subject,
html: htmlContent,
});
if (result.error) {
throw new Error(result.error.message);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Resend notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendDiscordNotification = async (
connection: typeof discord.$inferInsert,
embed: any,
@@ -176,6 +207,33 @@ export const sendNtfyNotification = async (
}
};
export const sendMattermostNotification = async (
connection: typeof mattermost.$inferInsert,
message: any,
) => {
const payload = {
...message,
// Only include username if it's provided and not empty
...(message.username?.trim() && { username: message.username }),
// Only include channel if it's provided and not empty
...(message.channel?.trim() && {
channel: `#${message.channel.replace("#", "")}`,
}),
};
const response = await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to send Mattermost notification: ${response.statusText}`,
);
}
};
export const sendCustomNotification = async (
connection: typeof custom.$inferInsert,
payload: Record<string, any>,
@@ -223,3 +281,111 @@ export const sendLarkNotification = async (
console.log(err);
}
};
export interface TeamsAdaptiveCardMessage {
title: string;
themeColor?: string;
facts?: { name: string; value: string }[];
potentialAction?: { type: "Action.OpenUrl"; title: string; url: string };
}
export const sendTeamsNotification = async (
connection: typeof teams.$inferInsert,
message: TeamsAdaptiveCardMessage,
) => {
try {
const bodyElements: Record<string, unknown>[] = [
{
type: "TextBlock",
text: message.title,
size: "Medium",
weight: "Bolder",
wrap: true,
},
];
if (message.facts && message.facts.length > 0) {
bodyElements.push({
type: "FactSet",
facts: message.facts.map((f) => ({
title: f.name,
value: f.value,
})),
});
}
const cardContent: Record<string, unknown> = {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.4",
body: bodyElements,
};
if (message.potentialAction) {
cardContent.actions = [
{
type: "Action.OpenUrl",
title: message.potentialAction.title,
url: message.potentialAction.url,
},
];
}
const payload = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: cardContent,
},
],
};
const response = await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`Failed to send Teams notification: ${response.statusText}`,
);
}
} catch (err) {
console.log(err);
throw new Error(
`Failed to send Teams notification ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
};
export const sendPushoverNotification = async (
connection: typeof pushover.$inferInsert,
title: string,
message: string,
) => {
const formData = new URLSearchParams();
formData.append("token", connection.apiToken);
formData.append("user", connection.userKey);
formData.append("title", title);
formData.append("message", message);
formData.append("priority", connection.priority?.toString() || "0");
// For emergency priority (2), retry and expire are required
if (connection.priority === 2) {
formData.append("retry", connection.retry?.toString() || "30");
formData.append("expire", connection.expire?.toString() || "3600");
}
const response = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
`Failed to send Pushover notification: ${response.statusText}`,
);
}
};

View File

@@ -5,11 +5,17 @@ import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendLarkNotification,
sendMattermostNotification,
sendNtfyNotification,
sendPushoverNotification,
sendResendNotification,
sendSlackNotification,
sendTeamsNotification,
sendTelegramNotification,
} from "./utils";
@@ -33,7 +39,8 @@ export const sendVolumeBackupNotifications = async ({
| "mongodb"
| "mariadb"
| "redis"
| "compose";
| "compose"
| "libsql";
type: "error" | "success";
organizationId: string;
errorMessage?: string;
@@ -51,224 +58,440 @@ export const sendVolumeBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
teams: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy } = notification;
const {
email,
resend,
discord,
telegram,
slack,
gotify,
ntfy,
mattermost,
custom,
lark,
pushover,
teams,
} = notification;
if (email) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
try {
if (email || resend) {
const subject = `Volume Backup ${type === "success" ? "Successful" : "Failed"} - ${applicationName}`;
const htmlContent = await renderAsync(
VolumeBackupEmail({
projectName,
applicationName,
volumeName,
serviceType,
type,
errorMessage,
backupSize,
date: date.toISOString(),
}),
);
if (email) {
await sendEmailNotification(email, subject, htmlContent);
}
if (resend) {
await sendResendNotification(resend, subject, htmlContent);
}
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Volume Backup Successful")
: decorate(">", "`❌` Volume Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: decorate("`💾`", "Volume Name"),
value: volumeName,
inline: true,
},
{
name: decorate("`🔧`", "Service Type"),
value: serviceType,
inline: true,
},
...(backupSize
? [
{
name: decorate("`📊`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Volume Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("💾", `Volume Name: ${volumeName}`)}` +
`${decorate("🔧", `Service Type: ${serviceType}`)}` +
`${backupSize ? decorate("📊", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`💾Volume Name: ${volumeName}\n` +
`🔧Service Type: ${serviceType}\n` +
`${backupSize ? `📊Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize
? `\n<b>Backup Size:</b> ${backupSize}`
: "";
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Volume Backup Successful*"
: ":x: *Volume Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Project",
value: projectName,
short: true,
},
{
title: "Application",
value: applicationName,
short: true,
},
{
title: "Volume Name",
value: volumeName,
short: true,
},
{
title: "Service Type",
value: serviceType,
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Type",
value: type,
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
}
if (mattermost) {
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg =
type === "error" && errorMessage
? `\n\n**Error:**\n\`\`\`\n${errorMessage}\n\`\`\``
: "";
const sizeInfo = backupSize ? `\n**Backup Size:** ${backupSize}` : "";
await sendMattermostNotification(mattermost, {
text: `**${statusEmoji} Volume Backup ${typeStatus}**\n\n**Project:** ${projectName}\n**Application:** ${applicationName}\n**Volume Name:** ${volumeName}\n**Service Type:** ${serviceType}${sizeInfo}\n**Date:** ${format(date, "PP")}\n**Time:** ${format(date, "pp")}${errorMsg}`,
channel: mattermost.channel,
username: mattermost.username || "Dokploy",
});
}
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Volume Name:**\n${volumeName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Service Type:**\n${serviceType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
if (pushover) {
await sendPushoverNotification(
pushover,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`Project: ${projectName}\nApplication: ${applicationName}\nVolume: ${volumeName}\nService Type: ${serviceType}${backupSize ? `\nBackup Size: ${backupSize}` : ""}\nDate: ${date.toLocaleString()}${type === "error" && errorMessage ? `\nError: ${errorMessage}` : ""}`,
);
}
if (teams) {
const facts = [
{ name: "Project", value: projectName },
{ name: "Application", value: applicationName },
{ name: "Volume Name", value: volumeName },
{ name: "Service Type", value: serviceType },
{ name: "Date", value: format(date, "PP pp") },
{
name: "Status",
value: type === "success" ? "Successful" : "Failed",
},
];
if (backupSize) {
facts.push({ name: "Backup Size", value: backupSize });
}
if (type === "error" && errorMessage) {
facts.push({ name: "Error", value: errorMessage.substring(0, 500) });
}
await sendTeamsNotification(teams, {
title:
type === "success"
? "✅ Volume Backup Successful"
: "❌ Volume Backup Failed",
facts,
});
}
if (custom) {
await sendCustomNotification(custom, {
title: `Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
message:
type === "success"
? "Volume backup completed successfully"
: "Volume backup failed",
projectName,
applicationName,
volumeName,
serviceType,
type,
errorMessage,
backupSize,
date: date.toISOString(),
}),
);
await sendEmailNotification(email, subject, htmlContent);
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? decorate(">", "`✅` Volume Backup Successful")
: decorate(">", "`❌` Volume Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: decorate("`💾`", "Volume Name"),
value: volumeName,
inline: true,
},
{
name: decorate("`🔧`", "Service Type"),
value: serviceType,
inline: true,
},
...(backupSize
? [
{
name: decorate("`📊`", "Backup Size"),
value: backupSize,
inline: true,
},
]
: []),
{
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
inline: true,
},
...(type === "error" && errorMessage
? [
{
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
: []),
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Volume Backup Notification",
},
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("💾", `Volume Name: ${volumeName}`)}` +
`${decorate("🔧", `Service Type: ${serviceType}`)}` +
`${backupSize ? decorate("📊", `Backup Size: ${backupSize}`) : ""}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Volume Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`💾Volume Name: ${volumeName}\n` +
`🔧Service Type: ${serviceType}\n` +
`${backupSize ? `📊Backup Size: ${backupSize}\n` : ""}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const sizeInfo = backupSize ? `\n<b>Backup Size:</b> ${backupSize}` : "";
const messageText = `<b>${statusEmoji} Volume Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Volume Name:</b> ${volumeName}\n<b>Service Type:</b> ${serviceType}${sizeInfo}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}
if (slack) {
const { channel } = slack;
await sendSlackNotification(slack, {
channel: channel,
attachments: [
{
color: type === "success" ? "#00FF00" : "#FF0000",
pretext:
type === "success"
? ":white_check_mark: *Volume Backup Successful*"
: ":x: *Volume Backup Failed*",
fields: [
...(type === "error" && errorMessage
? [
{
title: "Error Message",
value: errorMessage,
short: false,
},
]
: []),
{
title: "Project",
value: projectName,
short: true,
},
{
title: "Application",
value: applicationName,
short: true,
},
{
title: "Volume Name",
value: volumeName,
short: true,
},
{
title: "Service Type",
value: serviceType,
short: true,
},
...(backupSize
? [
{
title: "Backup Size",
value: backupSize,
short: true,
},
]
: []),
{
title: "Time",
value: date.toLocaleString(),
short: true,
},
{
title: "Type",
value: type,
short: true,
},
{
title: "Status",
value: type === "success" ? "Successful" : "Failed",
short: true,
},
],
},
],
});
errorMessage: errorMessage ?? "",
backupSize: backupSize ?? "",
timestamp: date.toISOString(),
date: date.toLocaleString(),
status: type,
});
}
} catch (error) {
console.log(error);
}
}
};

View File

@@ -201,14 +201,31 @@ export const execAsyncRemote = async (
.on("error", (err) => {
conn.end();
if (err.level === "client-authentication") {
const technicalDetail = `Error: ${err.message} ${err.level}`;
const friendlyMessage = [
"",
"❌ Couldn't connect to your server — the SSH key was not accepted.",
"",
"This usually means the key doesn't match what's on the server, or the key format is invalid.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
" • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
" • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab and then click on deployments tab and check the logs for more details.",
].join("\n");
const errorMsg = `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`;
onData?.(errorMsg);
onData?.(friendlyMessage);
reject(
new ExecError(errorMsg, {
command,
serverId,
originalError: err,
}),
new ExecError(
`Authentication failed: Invalid SSH private key. ${friendlyMessage}`,
{
command,
serverId,
originalError: err,
},
),
);
} else {
const errorMsg = `SSH connection error: ${err.message}`;

View File

@@ -10,6 +10,7 @@ import {
} from "@dokploy/server/services/bitbucket";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
export type ApplicationWithBitbucket = InferResultType<
"applications",
@@ -79,12 +80,14 @@ export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => {
interface CloneBitbucketRepository {
appName: string;
bitbucketRepository: string | null;
bitbucketRepositorySlug?: string | null;
bitbucketOwner: string | null;
bitbucketBranch: string | null;
bitbucketId: string | null;
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneBitbucketRepository = async ({
@@ -100,6 +103,7 @@ export const cloneBitbucketRepository = async ({
bitbucketId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -114,10 +118,11 @@ export const cloneBitbucketRepository = async ({
return command;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoclone = `bitbucket.org/${bitbucketOwner}/${bitbucketRepository}.git`;
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
@@ -137,6 +142,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
let repositories: {
name: string;
url: string;
slug: string;
owner: { username: string };
}[] = [];
@@ -159,6 +165,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
const mappedData = data.values.map((repo: any) => ({
name: repo.name,
url: repo.links.html.href,
slug: repo.slug,
owner: {
username: repo.workspace.slug,
},
@@ -173,7 +180,7 @@ export const getBitbucketRepositories = async (bitbucketId?: string) => {
};
export const getBitbucketBranches = async (
input: typeof apiFindBitbucketBranches._type,
input: z.infer<typeof apiFindBitbucketBranches>,
) => {
if (!input.bitbucketId) {
return [];
@@ -228,7 +235,7 @@ export const getBitbucketBranches = async (
};
export const testBitbucketConnection = async (
input: typeof apiBitbucketTestConnection._type,
input: z.infer<typeof apiBitbucketTestConnection>,
) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);

View File

@@ -14,6 +14,7 @@ interface CloneGitRepository {
enableSubmodules?: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitRepository = async ({
@@ -28,6 +29,7 @@ export const cloneGitRepository = async ({
customGitSSHKeyId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -47,7 +49,7 @@ export const cloneGitRepository = async ({
`;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
if (!isHttpOrHttps(customGitUrl)) {

View File

@@ -49,7 +49,9 @@ export const refreshGiteaToken = async (giteaProviderId: string) => {
}
// Token is expired or about to expire, refresh it
const tokenEndpoint = `${giteaProvider.giteaUrl}/login/oauth/access_token`;
// Use internal URL when Gitea is on same instance as Dokploy
const baseUrl = giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl;
const tokenEndpoint = `${baseUrl}/login/oauth/access_token`;
const params = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: giteaProvider.refreshToken,
@@ -128,6 +130,7 @@ interface CloneGiteaRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGiteaRepository = async ({
@@ -143,6 +146,7 @@ export const cloneGiteaRepository = async ({
giteaRepository,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -160,13 +164,13 @@ export const cloneGiteaRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoClone = `${giteaOwner}/${giteaRepository}.git`;
const cloneUrl = buildGiteaCloneUrl(
giteaProvider.giteaUrl,
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl,
giteaProvider.accessToken!,
giteaOwner!,
giteaRepository!,
@@ -207,7 +211,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
});
}
const baseUrl = provider.giteaUrl.replace(/\/+$/, "");
const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
/\/+$/,
"",
);
// Use /user/repos to get authenticated user's repositories with pagination
let allRepos = 0;
@@ -264,7 +271,9 @@ export const getGiteaRepositories = async (giteaId?: string) => {
await refreshGiteaToken(giteaId);
const giteaProvider = await findGiteaById(giteaId);
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
// Use /user/repos to get authenticated user's repositories with pagination
let allRepositories: any[] = [];
@@ -329,7 +338,9 @@ export const getGiteaBranches = async (input: {
const giteaProvider = await findGiteaById(input.giteaId);
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
// Handle pagination for branches
let allBranches: any[] = [];

View File

@@ -6,6 +6,7 @@ import type { InferResultType } from "@dokploy/server/types/with";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
import type { z } from "zod";
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {
@@ -121,6 +122,7 @@ interface CloneGithubRepository {
type?: "application" | "compose";
enableSubmodules: boolean;
serverId: string | null;
outputPathOverride?: string;
}
export const cloneGithubRepository = async ({
type = "application",
@@ -136,6 +138,7 @@ export const cloneGithubRepository = async ({
githubId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -155,7 +158,7 @@ export const cloneGithubRepository = async ({
const githubProvider = await findGithubById(githubId);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;
@@ -195,7 +198,7 @@ export const getGithubRepositories = async (githubId?: string) => {
};
export const getGithubBranches = async (
input: typeof apiFindGithubBranches._type,
input: z.infer<typeof apiFindGithubBranches>,
) => {
if (!input.githubId) {
return [];

View File

@@ -8,6 +8,7 @@ import {
} from "@dokploy/server/services/gitlab";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
@@ -21,7 +22,9 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => {
return;
}
const response = await fetch(`${gitlabProvider.gitlabUrl}/oauth/token`, {
// Use internal URL for token refresh when GitLab is on same instance as Dokploy
const baseUrl = gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl;
const response = await fetch(`${baseUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -87,12 +90,14 @@ const getGitlabRepoClone = (
gitlab: GitlabInfo,
gitlabPathNamespace: string | null,
) => {
const repoClone = `${gitlab?.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
const repoClone = `${url?.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
return repoClone;
};
const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => {
const isSecure = gitlab?.gitlabUrl.startsWith("https://");
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
const isSecure = url?.startsWith("https://");
const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`;
return cloneUrl;
};
@@ -105,6 +110,7 @@ interface CloneGitlabRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitlabRepository = async ({
@@ -119,6 +125,7 @@ export const cloneGitlabRepository = async ({
gitlabPathNamespace,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -139,7 +146,7 @@ export const cloneGitlabRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
@@ -167,7 +174,7 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
if (groupName) {
return groupName
.split(",")
.some((name) =>
.some((name: string) =>
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
);
}
@@ -209,10 +216,13 @@ export const getGitlabBranches = async (input: {
const allBranches = [];
let page = 1;
const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) {
const branchesResponse = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
@@ -252,7 +262,7 @@ export const getGitlabBranches = async (input: {
};
export const testGitlabConnection = async (
input: typeof apiGitlabTestConnection._type,
input: z.infer<typeof apiGitlabTestConnection>,
) => {
const { gitlabId, groupName } = input;
@@ -272,7 +282,7 @@ export const testGitlabConnection = async (
if (groupName) {
return groupName
.split(",")
.some((name) =>
.some((name: string) =>
full_path.toLowerCase().startsWith(name.trim().toLowerCase()),
);
}
@@ -287,10 +297,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
const allProjects = [];
let page = 1;
const perPage = 100; // GitLab's max per page is 100
const baseUrl = (
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
).replace(/\/+$/, "");
while (true) {
const response = await fetch(
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,

View File

@@ -32,7 +32,7 @@ export const restoreComposeBackup = async (
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
}
let credentials: DatabaseCredentials;
let credentials: DatabaseCredentials = {};
switch (backupInput.databaseType) {
case "postgres":
@@ -62,13 +62,18 @@ export const restoreComposeBackup = async (
const restoreCommand = getRestoreCommand({
appName: appName,
serviceName: backupInput.metadata?.serviceName,
type: backupInput.databaseType,
type: backupInput.databaseType as
| "postgres"
| "mariadb"
| "mysql"
| "mongo",
credentials: {
database: backupInput.databaseName,
...credentials,
},
restoreType: composeType,
rcloneCommand,
backupFile: backupInput.backupFile,
});
emit("Starting restore...");

View File

@@ -1,4 +1,5 @@
export { restoreComposeBackup } from "./compose";
export { restoreLibsqlBackup } from "./libsql";
export { restoreMariadbBackup } from "./mariadb";
export { restoreMongoBackup } from "./mongo";
export { restoreMySqlBackup } from "./mysql";

View File

@@ -0,0 +1,49 @@
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
import type { Destination } from "@dokploy/server/services/destination";
import type { Libsql } from "@dokploy/server/services/libsql";
import type { z } from "zod";
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
export const restoreLibsqlBackup = async (
libsql: Libsql,
destination: Destination,
backupInput: z.infer<typeof apiRestoreBackup>,
emit: (log: string) => void,
) => {
try {
const { appName, serverId } = libsql;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
const containerSearch = getServiceContainerCommand(appName);
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
emit(`Executing command: ${command}`);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
emit("Restore completed successfully!");
} catch (error) {
emit(
`Error: ${
error instanceof Error ? error.message : "Error restoring libsql backup"
}`,
);
throw error;
}
};

View File

@@ -30,7 +30,7 @@ export const getMongoRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`;
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
};
export const getComposeSearchCommand = (

View File

@@ -1,6 +1,6 @@
import { createWriteStream } from "node:fs";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import type { Schedule } from "@dokploy/server/db/schema/schedule";
import {
createDeploymentSchedule,
@@ -93,6 +93,13 @@ export const runCommand = async (scheduleId: string) => {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
try {
if (IS_CLOUD) {
writeStream.write(
"This feature is not available in the cloud version.",
);
writeStream.end();
return;
}
writeStream.write(
`docker exec ${containerId} ${shellType} -c ${command}\n`,
);

View File

@@ -5,7 +5,7 @@ import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { parse, stringify } from "yaml";
import { encodeBase64 } from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
export const createTraefikConfig = (appName: string) => {
@@ -57,18 +57,16 @@ export const removeTraefikConfig = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const command = `rm -f ${configPath}`;
if (serverId) {
await execAsyncRemote(serverId, `rm ${configPath}`);
await execAsyncRemote(serverId, command);
} else {
if (fs.existsSync(configPath)) {
await fs.promises.unlink(configPath);
}
await execAsync(command);
}
if (fs.existsSync(configPath)) {
await fs.promises.unlink(configPath);
}
} catch {}
} catch (error) {
console.error(`Error removing traefik config for ${appName}:`, error);
}
};
export const removeTraefikConfigRemote = async (
@@ -78,8 +76,13 @@ export const removeTraefikConfigRemote = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
await execAsyncRemote(serverId, `rm ${configPath}`);
} catch {}
await execAsyncRemote(serverId, `rm -f ${configPath}`);
} catch (error) {
console.error(
`Error removing remote traefik config for ${appName}:`,
error,
);
}
};
export const loadOrCreateConfig = (appName: string): FileConfig => {

View File

@@ -32,10 +32,10 @@ export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
config.http.routers[routerName] = await createRouterConfig(
app,
domain,
"web",
domain.customEntrypoint || "web",
);
if (domain.https) {
if (!domain.customEntrypoint && domain.https) {
config.http.routers[routerNameSecure] = await createRouterConfig(
app,
domain,
@@ -104,18 +104,40 @@ export const removeDomain = async (
}
};
/**
* Converts an internationalized domain name (IDN) to ASCII punycode format.
* Traefik requires domain names in ASCII format, so non-ASCII characters
* must be converted (e.g., "тест.рф" → "xn--e1aybc.xn--p1ai").
*/
const toPunycode = (host: string): string => {
try {
return new URL(`http://${host}`).hostname;
} catch {
// If URL parsing fails, return the original host
return host;
}
};
export const createRouterConfig = async (
app: ApplicationNested,
domain: Domain,
entryPoint: "web" | "websecure",
entryPoint: string,
) => {
const { appName, redirects, security } = app;
const { certificateType } = domain;
const { host, path, https, uniqueConfigKey, internalPath, stripPath } =
domain;
const {
host,
path,
https,
uniqueConfigKey,
internalPath,
stripPath,
customEntrypoint,
} = domain;
const punycodeHost = toPunycode(host);
const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`,
middlewares: [],
entryPoints: [entryPoint],
@@ -137,16 +159,13 @@ export const createRouterConfig = async (
}
if ((entryPoint === "websecure" && https) || !https) {
// redirects
for (const redirect of redirects) {
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
if (domain.domainType === "preview") {
middlewareName = `redirect-${appName.replace(
/^preview-(.+)-[^-]+$/,
"$1",
)}-${redirect.uniqueConfigKey}`;
// redirects - skip for preview deployments as wildcard subdomains
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {
for (const redirect of redirects) {
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
routerConfig.middlewares?.push(middlewareName);
}
routerConfig.middlewares?.push(middlewareName);
}
// security
@@ -167,7 +186,7 @@ export const createRouterConfig = async (
}
}
if (entryPoint === "websecure") {
if (entryPoint === "websecure" || (customEntrypoint && https)) {
if (certificateType === "letsencrypt") {
routerConfig.tls = { certResolver: "letsencrypt" };
} else if (certificateType === "custom" && domain.customCertResolver) {

View File

@@ -2,7 +2,30 @@ import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { findComposeById } from "@dokploy/server/services/compose";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
import {
getBackupTimestamp,
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>>,
@@ -10,17 +33,18 @@ export const backupVolume = async (
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 backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${getBackupTimestamp()}.tar`;
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}"
@@ -33,6 +57,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 ✅"
@@ -42,19 +69,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(
@@ -69,25 +140,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}
`);
}
};

View File

@@ -12,7 +12,7 @@ import {
import { scheduledJobs, scheduleJob } from "node-schedule";
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
import { backupVolume } from "./backup";
import { backupVolume, getVolumeServiceAppName } from "./backup";
// Helper functions to extract project info from volume backup
const getProjectName = (
@@ -26,6 +26,7 @@ const getProjectName = (
volumeBackup.mariadb,
volumeBackup.mongo,
volumeBackup.redis,
volumeBackup.libsql,
];
for (const service of services) {
@@ -48,6 +49,7 @@ const getOrganizationId = (
volumeBackup.mariadb,
volumeBackup.mongo,
volumeBackup.redis,
volumeBackup.libsql,
];
for (const service of services) {
@@ -81,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}`;
@@ -131,14 +133,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb"
: volumeBackup.serviceType;
await sendVolumeBackupNotifications({
projectName,
applicationName: volumeBackup.name,
volumeName: volumeBackup.volumeName,
serviceType: mappedServiceType,
type: "success",
organizationId,
});
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(
@@ -160,14 +169,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb"
: volumeBackup.serviceType;
await sendVolumeBackupNotifications({
projectName,
applicationName: volumeBackup.name,
volumeName: volumeBackup.volumeName,
serviceType: mappedServiceType,
type: "error",
organizationId,
errorMessage: error instanceof Error ? error.message : String(error),
});
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,
);
}
}
};