Merge branch 'Dokploy:canary' into feat/quick-service-switcher

This commit is contained in:
Mohammed Imran
2026-03-09 21:03:36 +05:30
committed by GitHub
37 changed files with 380 additions and 209 deletions

View File

@@ -2,8 +2,24 @@ import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
export const DOCKER_HOST = process.env.DOCKER_HOST;
export const DOCKER_PORT = process.env.DOCKER_PORT
? Number(process.env.DOCKER_PORT)
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker();
export const docker = new Docker({
...(DOCKER_API_VERSION && {
version: DOCKER_API_VERSION,
}),
...(DOCKER_HOST && {
host: DOCKER_HOST,
}),
...(DOCKER_PORT && {
port: DOCKER_PORT,
}),
});
// When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced .

View File

@@ -365,12 +365,13 @@ const createSchema = createInsertSchema(applications, {
previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(),
watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
enableSubmodules: z.boolean().optional(),
});
export const apiCreateApplication = createSchema.pick({
@@ -433,13 +434,13 @@ export const apiSaveGithubProvider = createSchema
owner: true,
buildPath: true,
githubId: true,
watchPaths: true,
enableSubmodules: true,
})
.required()
.extend({
triggerType: z.enum(["push", "tag"]).default("push"),
});
})
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGitlabProvider = createSchema
.pick({
@@ -451,10 +452,9 @@ export const apiSaveGitlabProvider = createSchema
gitlabId: true,
gitlabProjectId: true,
gitlabPathNamespace: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveBitbucketProvider = createSchema
.pick({
@@ -465,10 +465,9 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketRepositorySlug: true,
bitbucketId: true,
applicationId: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGiteaProvider = createSchema
.pick({
@@ -478,10 +477,9 @@ export const apiSaveGiteaProvider = createSchema
giteaOwner: true,
giteaRepository: true,
giteaId: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveDockerProvider = createSchema
.pick({
@@ -506,6 +504,7 @@ export const apiSaveGitProvider = createSchema
.merge(
createSchema.pick({
customGitSSHKeyId: true,
enableSubmodules: true,
}),
);

View File

@@ -135,15 +135,25 @@ export const getTrustedOrigins = async () => {
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
return trustedOriginsCache.data;
}
const trustedOrigins = await runQuery();
trustedOriginsCache = {
data: trustedOrigins,
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
};
return trustedOrigins;
try {
const trustedOrigins = await runQuery();
trustedOriginsCache = {
data: trustedOrigins,
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
};
return trustedOrigins;
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return trustedOriginsCache?.data ?? [];
}
}
return runQuery();
try {
return await runQuery();
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return [];
}
};
export const getTrustedProviders = async () => {

View File

@@ -117,12 +117,12 @@ export const createDeployment = async (
>,
) => {
const application = await findApplicationById(deployment.applicationId);
await removeLastTenDeployments(
deployment.applicationId,
"application",
application.serverId,
);
try {
await removeLastTenDeployments(
deployment.applicationId,
"application",
application.serverId,
);
const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId);
@@ -200,13 +200,12 @@ export const createDeploymentPreview = async (
const previewDeployment = await findPreviewDeploymentById(
deployment.previewDeploymentId,
);
await removeLastTenDeployments(
deployment.previewDeploymentId,
"previewDeployment",
previewDeployment?.application?.serverId,
);
try {
await removeLastTenDeployments(
deployment.previewDeploymentId,
"previewDeployment",
previewDeployment?.application?.serverId,
);
const appName = `${previewDeployment.appName}`;
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
@@ -281,12 +280,12 @@ export const createDeploymentCompose = async (
>,
) => {
const compose = await findComposeById(deployment.composeId);
await removeLastTenDeployments(
deployment.composeId,
"compose",
compose.serverId,
);
try {
await removeLastTenDeployments(
deployment.composeId,
"compose",
compose.serverId,
);
const { LOGS_PATH } = paths(!!compose.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${compose.appName}-${formattedDateTime}.log`;
@@ -369,8 +368,8 @@ export const createDeploymentBackup = async (
} else if (backup.backupType === "compose") {
serverId = backup.compose?.serverId;
}
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
try {
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${backup.appName}-${formattedDateTime}.log`;
@@ -439,12 +438,12 @@ export const createDeploymentSchedule = async (
) => {
const schedule = await findScheduleById(deployment.scheduleId);
const serverId =
schedule.application?.serverId ||
schedule.compose?.serverId ||
schedule.server?.serverId;
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
try {
const serverId =
schedule.application?.serverId ||
schedule.compose?.serverId ||
schedule.server?.serverId;
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
const { SCHEDULES_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
@@ -515,14 +514,14 @@ export const createDeploymentVolumeBackup = async (
) => {
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
await removeLastTenDeployments(
deployment.volumeBackupId,
"volumeBackup",
serverId,
);
try {
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
await removeLastTenDeployments(
deployment.volumeBackupId,
"volumeBackup",
serverId,
);
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
@@ -601,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
.then((result) => result[0]);
if (!deployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment not found",
});
return null;
}
const command = `
rm -f ${deployment.logPath};
`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
const logPath = path.join(deployment.logPath);
if (logPath && logPath !== ".") {
const command = `rm -f ${logPath};`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
}
}
return deployment;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error creating the deployment";
error instanceof Error ? error.message : "Error removing the deployment";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -686,34 +684,49 @@ const removeLastTenDeployments = async (
if (serverId) {
let command = "";
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
try {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
if (logPath !== ".") {
command += `
rm -rf ${logPath};
`;
if (logPath && logPath !== ".") {
command += `rm -rf ${logPath};`;
}
await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
await removeDeployment(oldDeployment.deploymentId);
}
await execAsyncRemote(serverId, command);
if (command) {
await execAsyncRemote(serverId, command);
}
} else {
for (const oldDeployment of deploymentsToDelete) {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
try {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
const logPath = path.join(oldDeployment.logPath);
if (
logPath &&
logPath !== "." &&
existsSync(logPath) &&
!oldDeployment.errorMessage
) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
const logPath = path.join(oldDeployment.logPath);
if (
existsSync(logPath) &&
!oldDeployment.errorMessage &&
logPath !== "."
) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
}

View File

@@ -16,7 +16,7 @@ function shEscape(s: string | undefined): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function safeDockerLoginCommand(
export function safeDockerLoginCommand(
registry: string | undefined,
user: string | undefined,
pass: string | undefined,

View File

@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount";
import type { Port } from "./port";
import type { Project } from "./project";
import type { Registry } from "./registry";
import { type Registry, safeDockerLoginCommand } from "./registry";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
const command = `docker image rm ${image} --force`;
if (serverId) {
await execAsyncRemote(command, serverId);
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
);
};
const dockerLoginForRegistry = async (
registry: Registry,
serverId?: string | null,
) => {
const loginCommand = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
if (serverId) {
await execAsyncRemote(serverId, loginCommand);
} else {
await execAsync(loginCommand);
}
};
const rollbackApplication = async (
appName: string,
image: string,
@@ -188,6 +205,14 @@ const rollbackApplication = async (
throw new Error("Full context is required for rollback");
}
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
if (fullContext.rollbackRegistry) {
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId);
// Use the same configuration as mechanizeDockerContainer

View File

@@ -413,17 +413,38 @@ export const checkPortInUse = async (
serverId?: string,
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
try {
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
// Check if port is in use by a Docker container
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
const { stdout: dockerOut } = serverId
? await execAsyncRemote(serverId, dockerCommand)
: await execAsync(dockerCommand);
const container = stdout.trim();
const container = dockerOut.trim();
return {
isInUse: !!container,
conflictingContainer: container || undefined,
};
if (container) {
return {
isInUse: true,
conflictingContainer: `container "${container}"`,
};
}
// Check if port is in use by a host-level service (non-Docker)
// Dokploy runs inside a container, so we spawn an ephemeral container
// with --net=host to share the host's network stack and use nc -z to
// check if something is listening on the port
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
const { stdout: hostOut } = serverId
? await execAsyncRemote(serverId, hostCommand)
: await execAsync(hostCommand);
if (hostOut.includes("in_use")) {
return {
isInUse: true,
conflictingContainer: "a host-level service",
};
}
return { isInUse: false };
} catch (error) {
console.error("Error checking port availability:", error);
return { isInUse: false };

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,

View File

@@ -14,13 +14,14 @@ 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 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,3 @@
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";
@@ -11,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....");
@@ -107,6 +106,20 @@ export const initCronJobs = async () => {
}
};
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;
return serviceAppName || backup.appName;
};
export const keepLatestNBackups = async (
backup: BackupSchedule,
serverId?: string | null,
@@ -117,18 +130,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}`;
// 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

@@ -14,13 +14,13 @@ 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MariaDB Backup",

View File

@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MongoDB Backup",

View File

@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MySQL Backup",

View File

@@ -14,7 +14,7 @@ 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);
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
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 +67,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 ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");

View File

@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
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` : ""}

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;

View File

@@ -211,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;
@@ -268,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[] = [];
@@ -333,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

@@ -214,10 +214,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}`,
@@ -292,10 +295,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

@@ -69,6 +69,7 @@ export const restoreComposeBackup = async (
},
restoreType: composeType,
rcloneCommand,
backupFile: backupInput.backupFile,
});
emit("Starting restore...");

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

@@ -152,16 +152,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

View File

@@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose";
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
export const getVolumeServiceAppName = (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
): string => {
if (volumeBackup.compose?.appName) {
return volumeBackup.serviceName
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
: volumeBackup.compose.appName;
}
const serviceAppName =
volumeBackup.application?.appName ||
volumeBackup.postgres?.appName ||
volumeBackup.mysql?.appName ||
volumeBackup.mariadb?.appName ||
volumeBackup.mongo?.appName ||
volumeBackup.redis?.appName;
return serviceAppName || volumeBackup.appName;
};
export const backupVolume = async (
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
) => {
@@ -12,8 +30,9 @@ export const backupVolume = async (
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
const destination = volumeBackup.destination;
const s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
const rcloneFlags = getS3Credentials(volumeBackup.destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);

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 = (
@@ -81,9 +81,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 +131,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 +167,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,
);
}
}
};