mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-22 04:54:29 +00:00
Merge branch 'canary' into feat/domain-enable-disable
This commit is contained in:
@@ -120,7 +120,7 @@ export function parseRawConfig(
|
||||
|
||||
if (search) {
|
||||
parsedLogs = parsedLogs.filter((log) =>
|
||||
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
|
||||
(log.RequestHost ?? "").toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,10 @@ export const runComposeBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (compose.serverId) {
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runLibsqlBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (libsql.serverId) {
|
||||
|
||||
@@ -35,11 +35,10 @@ export const runMariadbBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (mariadb.serverId) {
|
||||
|
||||
@@ -32,11 +32,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -33,12 +33,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runPostgresBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (postgres.serverId) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { quote } from "shell-quote";
|
||||
import { keepLatestNBackups } from ".";
|
||||
import { runComposeBackup } from "./compose";
|
||||
import { runLibsqlBackup } from "./libsql";
|
||||
@@ -71,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
const { accessKey, secretAccessKey, region, endpoint, provider } =
|
||||
destination;
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||
`--s3-region="${region}"`,
|
||||
`--s3-endpoint="${endpoint}"`,
|
||||
`--s3-access-key-id=${quote([accessKey])}`,
|
||||
`--s3-secret-access-key=${quote([secretAccessKey])}`,
|
||||
`--s3-region=${quote([region])}`,
|
||||
`--s3-endpoint=${quote([endpoint])}`,
|
||||
"--s3-no-check-bucket",
|
||||
"--s3-force-path-style",
|
||||
];
|
||||
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
|
||||
}
|
||||
|
||||
if (destination.additionalFlags?.length) {
|
||||
@@ -90,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
return rcloneFlags;
|
||||
};
|
||||
|
||||
// User-controlled values (database name, user, password) are passed to the
|
||||
// container as environment variables via `docker exec -e VAR=<escaped>` and
|
||||
// referenced as "$VAR" inside the inner shell, so they never appear in the
|
||||
// inner command text. The -e value is escaped for the outer shell with
|
||||
// shell-quote; the inner script is single-quoted and reads the env vars.
|
||||
export const getPostgresBackupCommand = (
|
||||
database: string,
|
||||
databaseUser: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMariadbBackupCommand = (
|
||||
@@ -102,14 +108,14 @@ export const getMariadbBackupCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMysqlBackupCommand = (
|
||||
database: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getMongoBackupCommand = (
|
||||
@@ -117,11 +123,11 @@ export const getMongoBackupCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;
|
||||
};
|
||||
|
||||
export const getLibsqlBackupCommand = (database: string) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
|
||||
};
|
||||
|
||||
export const getServiceContainerCommand = (appName: string) => {
|
||||
@@ -253,11 +259,14 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
|
||||
|
||||
export const getBackupCommand = (
|
||||
backup: BackupSchedule,
|
||||
rcloneCommand: string,
|
||||
rcloneFlags: string[],
|
||||
rcloneDestination: string,
|
||||
logPath: string,
|
||||
) => {
|
||||
const containerSearch = getContainerSearchCommand(backup);
|
||||
const backupCommand = generateBackupCommand(backup);
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
const rcloneDeleteCommand = `rclone deletefile ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -273,33 +282,24 @@ export const getBackupCommand = (
|
||||
set -eo pipefail;
|
||||
echo "[$(date)] Starting backup process..." >> ${logPath};
|
||||
echo "[$(date)] Executing backup command..." >> ${logPath};
|
||||
CONTAINER_ID=$(${containerSearch})
|
||||
CONTAINER_ID=$(${containerSearch});
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
|
||||
exit 1;
|
||||
fi
|
||||
fi;
|
||||
|
||||
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
|
||||
echo "[$(date)] Starting backup and upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the backup command and capture the exit status
|
||||
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
|
||||
UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
|
||||
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
|
||||
exit 1;
|
||||
}
|
||||
|
||||
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
|
||||
echo "[$(date)] Starting upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the upload command and capture the exit status
|
||||
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
|
||||
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
|
||||
${rcloneDeleteCommand} >/dev/null 2>&1 || true;
|
||||
exit 1;
|
||||
}
|
||||
};
|
||||
|
||||
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
|
||||
echo "[$(date)] ✅ Backup uploaded to S3 successfully" >> ${logPath};
|
||||
echo "Backup done ✅" >> ${logPath};
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -7,19 +7,23 @@ import { writeDomainsToCompose } from "../docker/domain";
|
||||
import {
|
||||
encodeBase64,
|
||||
getEnvironmentVariablesObject,
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
} from "../docker/utils";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type ComposeNested = InferResultType<
|
||||
"compose",
|
||||
{ environment: { with: { project: true } }; mounts: true; domains: true }
|
||||
>;
|
||||
|
||||
export const getBuildComposeCommand = async (compose: ComposeNested) => {
|
||||
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
const envCommand = getCreateEnvFileCommand(compose);
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose)
|
||||
: "";
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const exportEnvCommand = getExportEnvCommand(compose);
|
||||
|
||||
@@ -67,11 +71,48 @@ Compose Type: ${composeType} ✅`;
|
||||
return bashCommand;
|
||||
};
|
||||
|
||||
// Shell control characters that must never appear in a user-provided compose
|
||||
// command: they would let it break out of the `docker ${command}` invocation
|
||||
// into arbitrary host commands. A normal docker compose CLI line never needs them.
|
||||
// Removed '&' from the blocklist to allow '&&' chaining
|
||||
const UNSAFE_COMPOSE_COMMAND = /[;|`$(){}<>\n\\]/;
|
||||
|
||||
const sanitizeCommand = (command: string) => {
|
||||
const sanitizedCommand = command.trim();
|
||||
|
||||
const parts = sanitizedCommand.split(/\s+/);
|
||||
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
|
||||
throw new Error(
|
||||
"Invalid characters in compose command: shell control characters are not allowed",
|
||||
);
|
||||
}
|
||||
|
||||
if (sanitizedCommand.includes("&")) {
|
||||
// Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&'
|
||||
if (
|
||||
/(?<!&)&(?!&)/.test(sanitizedCommand) ||
|
||||
sanitizedCommand.includes("&&&")
|
||||
) {
|
||||
throw new Error("Single '&' is not allowed. Use '&&' for chaining.");
|
||||
}
|
||||
|
||||
// Split by '&&' and check that every chained command (skipping the first one) is safe
|
||||
const chains = sanitizedCommand.split("&&").map((cmd) => cmd.trim());
|
||||
const isSafeChain = chains
|
||||
.slice(1)
|
||||
.every(
|
||||
(cmd) =>
|
||||
cmd.startsWith("docker compose ") ||
|
||||
cmd.startsWith("docker-compose "),
|
||||
);
|
||||
|
||||
if (!isSafeChain) {
|
||||
throw new Error(
|
||||
"Chained commands must strictly start with 'docker compose '",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = sanitizedCommand.split(/\s+/);
|
||||
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
|
||||
|
||||
return restCommand.join(" ");
|
||||
@@ -88,9 +129,9 @@ export const createCommand = (compose: ComposeNested) => {
|
||||
let command = "";
|
||||
|
||||
if (composeType === "docker-compose") {
|
||||
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
|
||||
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
|
||||
} else if (composeType === "stack") {
|
||||
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
|
||||
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||
}
|
||||
|
||||
return command;
|
||||
@@ -116,7 +157,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
||||
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
||||
}
|
||||
|
||||
const envFileContent = prepareEnvironmentVariables(
|
||||
const envFileContent = prepareEnvironmentVariablesForFile(
|
||||
envContent,
|
||||
compose.environment.project.env,
|
||||
compose.environment.env,
|
||||
@@ -124,8 +165,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
||||
|
||||
const encodedContent = encodeBase64(envFileContent);
|
||||
return `
|
||||
touch ${envFilePath};
|
||||
echo "${encodedContent}" | base64 -d > "${envFilePath}";
|
||||
touch ${quote([envFilePath])};
|
||||
echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
|
||||
`;
|
||||
};
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ export const getDockerCommand = (application: ApplicationNested) => {
|
||||
}
|
||||
|
||||
command += `
|
||||
echo "Building ${appName}" ;
|
||||
cd ${dockerContextPath} || {
|
||||
echo "❌ The path ${dockerContextPath} does not exist" ;
|
||||
echo ${quote([`Building ${appName}`])} ;
|
||||
cd ${quote([dockerContextPath])} || {
|
||||
echo ${quote([`❌ The path ${dockerContextPath} does not exist`])} ;
|
||||
exit 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveServiceNetworks } from "@dokploy/server/services/network";
|
||||
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
import { getDockerCommand } from "./docker-file";
|
||||
import { getHerokuCommand } from "./heroku";
|
||||
import { getNixpacksCommand } from "./nixpacks";
|
||||
@@ -37,7 +39,8 @@ export type ApplicationNested = InferResultType<
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
let command = "";
|
||||
|
||||
if (application.sourceType !== "docker") {
|
||||
@@ -76,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
};
|
||||
|
||||
export const mechanizeDockerContainer = async (
|
||||
application: ApplicationNested,
|
||||
rawApplication: ApplicationNested,
|
||||
) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -100,6 +104,8 @@ export const mechanizeDockerContainer = async (
|
||||
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(application);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -108,7 +114,6 @@ export const mechanizeDockerContainer = async (
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -147,7 +152,7 @@ export const mechanizeDockerContainer = async (
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
|
||||
import { nanoid } from "nanoid";
|
||||
import { quote } from "shell-quote";
|
||||
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
|
||||
import { getBuildAppDirectory } from "../filesystem/directory";
|
||||
import type { ApplicationNested } from ".";
|
||||
@@ -52,10 +53,10 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
|
||||
|
||||
bashCommand += `
|
||||
docker create --name ${buildContainerId} ${appName}
|
||||
mkdir -p ${localPath}
|
||||
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} || {
|
||||
mkdir -p ${quote([localPath])}
|
||||
docker cp ${quote([`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`])} ${quote([localPath])} || {
|
||||
docker rm ${buildContainerId}
|
||||
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" ;
|
||||
echo ${quote([`❌ Copying ${publishDirectory} to ${localPath} failed`])} ;
|
||||
exit 1;
|
||||
}
|
||||
docker rm ${buildContainerId}
|
||||
|
||||
@@ -53,14 +53,9 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
"build",
|
||||
"--builder",
|
||||
builderName,
|
||||
...(cacheKey
|
||||
? [
|
||||
"--build-arg",
|
||||
`secrets-hash=${secretsHash}`,
|
||||
"--build-arg",
|
||||
`cache-key=${cacheKey}`,
|
||||
]
|
||||
: []),
|
||||
"--build-arg",
|
||||
`secrets-hash=${secretsHash}`,
|
||||
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
|
||||
"--build-arg",
|
||||
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`,
|
||||
"-f",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
safeDockerLoginCommand,
|
||||
} from "@dokploy/server/services/registry";
|
||||
import { createRollback } from "@dokploy/server/services/rollbacks";
|
||||
import { quote } from "shell-quote";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
export const uploadImageRemoteCommand = async (
|
||||
@@ -124,18 +125,18 @@ const getRegistryCommands = (
|
||||
registry.password,
|
||||
);
|
||||
return `
|
||||
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
|
||||
echo ${quote([`📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'`])} ;
|
||||
${loginCmd} || {
|
||||
echo "❌ DockerHub Failed" ;
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Registry Login Success" ;
|
||||
docker tag ${imageName} ${registryTag} || {
|
||||
docker tag ${quote([imageName])} ${quote([registryTag])} || {
|
||||
echo "❌ Error tagging image" ;
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Image Tagged" ;
|
||||
docker push ${registryTag} || {
|
||||
docker push ${quote([registryTag])} || {
|
||||
echo "❌ Error pushing image" ;
|
||||
exit 1;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions, PortConfig } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
@@ -17,7 +19,8 @@ export type LibsqlNested = InferResultType<
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
|
||||
const libsql = await withResolvedVaultRefs(rawLibsql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -46,6 +49,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(libsql);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -54,7 +59,6 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
} = generateConfigContainer(libsql);
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@@ -96,7 +100,7 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
: {}),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MariadbNested = InferResultType<
|
||||
"mariadb",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
export const buildMariadb = async (rawMariadb: MariadbNested) => {
|
||||
const mariadb = await withResolvedVaultRefs(rawMariadb);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -37,6 +40,8 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mariadb);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -45,7 +50,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -87,7 +91,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MongoNested = InferResultType<
|
||||
"mongo",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMongo = async (mongo: MongoNested) => {
|
||||
export const buildMongo = async (rawMongo: MongoNested) => {
|
||||
const mongo = await withResolvedVaultRefs(rawMongo);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -83,6 +86,8 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mongo);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -91,7 +96,6 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -143,7 +147,7 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MysqlNested = InferResultType<
|
||||
"mysql",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMysql = async (mysql: MysqlNested) => {
|
||||
export const buildMysql = async (rawMysql: MysqlNested) => {
|
||||
const mysql = await withResolvedVaultRefs(rawMysql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -43,6 +46,8 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mysql);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -51,7 +56,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -93,7 +97,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type PostgresNested = InferResultType<
|
||||
"postgres",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
export const buildPostgres = async (rawPostgres: PostgresNested) => {
|
||||
const postgres = await withResolvedVaultRefs(rawPostgres);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -36,6 +39,8 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(postgres);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -44,7 +49,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -85,7 +89,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { deployMySql } from "@dokploy/server/services/mysql";
|
||||
import { deployPostgres } from "@dokploy/server/services/postgres";
|
||||
import { deployRedis } from "@dokploy/server/services/redis";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import { removeService } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -40,7 +41,7 @@ export const rebuildDatabase = async (
|
||||
|
||||
for (const mount of database.mounts) {
|
||||
if (mount.type === "volume") {
|
||||
const command = `docker volume rm ${mount?.volumeName} --force`;
|
||||
const command = `docker volume rm ${quote([mount?.volumeName ?? ""])} --force`;
|
||||
if (database.serverId) {
|
||||
await execAsyncRemote(database.serverId, command);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type RedisNested = InferResultType<
|
||||
"redis",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildRedis = async (redis: RedisNested) => {
|
||||
export const buildRedis = async (rawRedis: RedisNested) => {
|
||||
const redis = await withResolvedVaultRefs(rawRedis);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -34,6 +37,8 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(redis);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -42,7 +47,6 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -91,7 +95,7 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -26,11 +26,14 @@ export const addSuffixToVolumesInServices = (
|
||||
if (_.has(newServiceConfig, "volumes")) {
|
||||
newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => {
|
||||
if (_.isString(volume)) {
|
||||
const [volumeName, path] = volume.split(":");
|
||||
// remainder is the container path plus optional access mode (:ro, :z, :Z)
|
||||
const [volumeName, ...pathAndMode] = volume.split(":");
|
||||
const remainder = pathAndMode.join(":");
|
||||
|
||||
// skip bind mounts and variables (e.g. $PWD)
|
||||
if (
|
||||
!volumeName ||
|
||||
!remainder ||
|
||||
volumeName.startsWith(".") ||
|
||||
volumeName.startsWith("/") ||
|
||||
volumeName.startsWith("$")
|
||||
@@ -43,10 +46,10 @@ export const addSuffixToVolumesInServices = (
|
||||
if (parts.length > 1) {
|
||||
const baseName = parts[0];
|
||||
const rest = parts.slice(1).join("/");
|
||||
return `${baseName}-${suffix}/${rest}:${path}`;
|
||||
return `${baseName}-${suffix}/${rest}:${remainder}`;
|
||||
}
|
||||
|
||||
return `${volumeName}-${suffix}:${path}`;
|
||||
return `${volumeName}-${suffix}:${remainder}`;
|
||||
}
|
||||
if (_.isObject(volume) && volume.type === "volume" && volume.source) {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs, { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { network, patch } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../providers/bitbucket";
|
||||
@@ -131,6 +134,38 @@ exit 1;
|
||||
`;
|
||||
}
|
||||
};
|
||||
export const applyComposeFilePatch = async (
|
||||
compose: Compose,
|
||||
): Promise<ComposeSpecification | null> => {
|
||||
if (compose.sourceType === "raw") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const composePatches = await db.query.patch.findMany({
|
||||
where: eq(patch.composeId, compose.composeId),
|
||||
});
|
||||
|
||||
const composeFilePatch = composePatches.find(
|
||||
(p) =>
|
||||
p.enabled &&
|
||||
p.type !== "delete" &&
|
||||
join(p.filePath) === join(compose.composePath),
|
||||
);
|
||||
|
||||
if (!composeFilePatch?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parse(composeFilePatch.content, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsed ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addDomainToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
@@ -149,6 +184,8 @@ export const addDomainToCompose = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
result = (await applyComposeFilePatch(compose)) ?? result;
|
||||
|
||||
if (compose.isolatedDeployment) {
|
||||
const randomized = randomizeDeployableSpecificationFile(
|
||||
result,
|
||||
@@ -218,6 +255,17 @@ export const addDomainToCompose = async (
|
||||
labels.unshift("traefik.swarm.network=dokploy-network");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isolatedNetwork = compose.suffix || compose.appName;
|
||||
if (compose.composeType === "docker-compose") {
|
||||
if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.docker.network=${isolatedNetwork}`);
|
||||
}
|
||||
} else {
|
||||
if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.swarm.network=${isolatedNetwork}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,14 +277,79 @@ export const addDomainToCompose = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Add dokploy-network to the root of the compose file
|
||||
const injectedNetworkNames = await applyServiceNetworks(result, compose);
|
||||
|
||||
if (!compose.isolatedDeployment) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks);
|
||||
declareUsedNetworksInRoot(result, injectedNetworkNames);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const applyServiceNetworks = async (
|
||||
result: ComposeSpecification,
|
||||
compose: Compose,
|
||||
) => {
|
||||
const injectedNetworkNames = new Set<string>();
|
||||
const serviceNetworks = compose.serviceNetworks ?? [];
|
||||
if (serviceNetworks.length === 0) return injectedNetworkNames;
|
||||
|
||||
const allNetworkIds = [
|
||||
...new Set(serviceNetworks.flatMap((s) => s.networkIds)),
|
||||
];
|
||||
const networks =
|
||||
allNetworkIds.length > 0
|
||||
? await db.query.network.findMany({
|
||||
where: inArray(network.networkId, allNetworkIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
for (const config of serviceNetworks) {
|
||||
const service = result.services?.[config.serviceName];
|
||||
if (!service) continue;
|
||||
|
||||
for (const networkId of config.networkIds) {
|
||||
const match = networks.find((n) => n.networkId === networkId);
|
||||
if (!match) continue;
|
||||
service.networks = addDokployNetworkToService(
|
||||
service.networks,
|
||||
match.name,
|
||||
);
|
||||
injectedNetworkNames.add(match.name);
|
||||
}
|
||||
|
||||
if (config.detachDokployNetwork) {
|
||||
removeNetworkFromService(service, "dokploy-network");
|
||||
removeNetworkFromService(service, "default");
|
||||
removeDokployNetworkLabel(service);
|
||||
}
|
||||
}
|
||||
|
||||
return injectedNetworkNames;
|
||||
};
|
||||
|
||||
export const declareUsedNetworksInRoot = (
|
||||
result: ComposeSpecification,
|
||||
injectedNetworkNames: Set<string>,
|
||||
) => {
|
||||
const isUsed = (name: string) =>
|
||||
Object.values(result.services ?? {}).some((service) => {
|
||||
const nets = service?.networks;
|
||||
if (Array.isArray(nets)) return nets.includes(name);
|
||||
if (nets && typeof nets === "object") return name in nets;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isUsed("dokploy-network")) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks);
|
||||
}
|
||||
for (const name of injectedNetworkNames) {
|
||||
if (isUsed(name)) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks, name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const writeComposeFile = async (
|
||||
compose: Compose,
|
||||
composeSpec: ComposeSpecification,
|
||||
@@ -350,9 +463,10 @@ export const createDomainLabels = (
|
||||
|
||||
export const addDokployNetworkToService = (
|
||||
networkService: DefinitionsService["networks"],
|
||||
networkName = "dokploy-network",
|
||||
) => {
|
||||
let networks = networkService;
|
||||
const network = "dokploy-network";
|
||||
const network = networkName;
|
||||
const defaultNetwork = "default";
|
||||
if (!networks) {
|
||||
networks = [];
|
||||
@@ -377,11 +491,40 @@ export const addDokployNetworkToService = (
|
||||
return networks;
|
||||
};
|
||||
|
||||
export const removeNetworkFromService = (
|
||||
service: DefinitionsService,
|
||||
networkName: string,
|
||||
) => {
|
||||
const networks = service.networks;
|
||||
if (Array.isArray(networks)) {
|
||||
service.networks = networks.filter((n) => n !== networkName);
|
||||
} else if (networks && typeof networks === "object") {
|
||||
delete networks[networkName];
|
||||
}
|
||||
};
|
||||
|
||||
const removeDokployNetworkLabel = (service: DefinitionsService) => {
|
||||
const stripped = (labels: DefinitionsService["labels"]) => {
|
||||
if (Array.isArray(labels)) {
|
||||
return labels.filter(
|
||||
(l) =>
|
||||
l !== "traefik.docker.network=dokploy-network" &&
|
||||
l !== "traefik.swarm.network=dokploy-network",
|
||||
);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
if (service.labels) service.labels = stripped(service.labels);
|
||||
if (service.deploy?.labels)
|
||||
service.deploy.labels = stripped(service.deploy.labels);
|
||||
};
|
||||
|
||||
export const addDokployNetworkToRoot = (
|
||||
networkRoot: PropertiesNetworks | undefined,
|
||||
networkName = "dokploy-network",
|
||||
) => {
|
||||
let networks = networkRoot;
|
||||
const network = "dokploy-network";
|
||||
const network = networkName;
|
||||
|
||||
if (!networks) {
|
||||
networks = {};
|
||||
|
||||
@@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
) => {
|
||||
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
|
||||
if (source?.includes("${{vault.")) {
|
||||
throw new Error(
|
||||
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectVars = parse(projectEnv ?? "");
|
||||
const environmentVars = parse(environmentEnv ?? "");
|
||||
const serviceVars = parse(serviceEnv ?? "");
|
||||
@@ -465,6 +472,27 @@ export const prepareEnvironmentVariablesForShell = (
|
||||
return envVars.map((env) => quote([env]));
|
||||
};
|
||||
|
||||
export const prepareEnvironmentVariablesForFile = (
|
||||
serviceEnv: string | null,
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
): string[] => {
|
||||
const envVars = prepareEnvironmentVariables(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
return envVars.map((pair) => {
|
||||
const [key, value] = parseEnvironmentKeyValuePair(pair);
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
export const parseEnvironmentKeyValuePair = (
|
||||
pair: string,
|
||||
): [string, string] => {
|
||||
@@ -548,7 +576,6 @@ export const generateConfigContainer = (
|
||||
labelsSwarm,
|
||||
replicas,
|
||||
mounts,
|
||||
networkSwarm,
|
||||
stopGracePeriodSwarm,
|
||||
endpointSpecSwarm,
|
||||
ulimitsSwarm,
|
||||
@@ -611,13 +638,6 @@ export const generateConfigContainer = (
|
||||
stopGracePeriodSwarm !== undefined && {
|
||||
StopGracePeriod: stopGracePeriodSwarm,
|
||||
}),
|
||||
...(networkSwarm
|
||||
? {
|
||||
Networks: networkSwarm,
|
||||
}
|
||||
: {
|
||||
Networks: [{ Target: "dokploy-network" }],
|
||||
}),
|
||||
...(endpointSpecSwarm && {
|
||||
EndpointSpec: {
|
||||
...(endpointSpecSwarm.Mode && { Mode: endpointSpecSwarm.Mode }),
|
||||
@@ -712,14 +732,14 @@ export const getCreateFileCommand = (
|
||||
) => {
|
||||
const fullPath = path.join(outputPath, filePath);
|
||||
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
|
||||
return `mkdir -p ${fullPath};`;
|
||||
return `mkdir -p ${quote([fullPath])};`;
|
||||
}
|
||||
|
||||
const directory = path.dirname(fullPath);
|
||||
const encodedContent = encodeBase64(content);
|
||||
return `
|
||||
mkdir -p ${directory};
|
||||
echo "${encodedContent}" | base64 -d > "${fullPath}";
|
||||
mkdir -p ${quote([directory])};
|
||||
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -900,50 +920,6 @@ export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { quote } from "shell-quote";
|
||||
import { execAsync, execAsyncRemote, sleep } from "../utils/process/execAsync";
|
||||
|
||||
interface GPUInfo {
|
||||
@@ -322,7 +323,7 @@ const setupLocalServer = async (daemonConfig: any) => {
|
||||
};
|
||||
|
||||
const addGpuLabel = async (nodeId: string, serverId?: string) => {
|
||||
const labelCommand = `docker node update --label-add gpu=true ${nodeId}`;
|
||||
const labelCommand = `docker node update --label-add gpu=true ${quote([nodeId])}`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, labelCommand);
|
||||
} else {
|
||||
@@ -335,7 +336,7 @@ const verifySetup = async (nodeId: string, serverId?: string) => {
|
||||
|
||||
if (!finalStatus.swarmEnabled) {
|
||||
const diagnosticCommands = [
|
||||
`docker node inspect ${nodeId}`,
|
||||
`docker node inspect ${quote([nodeId])}`,
|
||||
'nvidia-smi -a | grep "GPU UUID"',
|
||||
"cat /etc/docker/daemon.json",
|
||||
"cat /etc/nvidia-container-runtime/config.toml",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
|
||||
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
|
||||
// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected
|
||||
// because Let's Encrypt refuses to issue certificates for them.
|
||||
export const VALID_HOSTNAME_REGEX =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
|
||||
export const INVALID_HOSTNAME_MESSAGE =
|
||||
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@dokploy/server/services/bitbucket";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type ApplicationWithBitbucket = InferResultType<
|
||||
@@ -124,8 +125,8 @@ export const cloneBitbucketRepository = async ({
|
||||
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;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(bitbucketBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
|
||||
import { quote } from "shell-quote";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
export const buildRemoteDocker = async (application: ApplicationNested) => {
|
||||
@@ -9,7 +10,7 @@ export const buildRemoteDocker = async (application: ApplicationNested) => {
|
||||
throw new Error("Docker image not found");
|
||||
}
|
||||
let command = `
|
||||
echo "Pulling ${dockerImage}";
|
||||
echo ${quote([`Pulling ${dockerImage}`])};
|
||||
`;
|
||||
|
||||
if (username && password) {
|
||||
@@ -22,7 +23,7 @@ fi
|
||||
}
|
||||
|
||||
command += `
|
||||
docker pull ${dockerImage} 2>&1 || {
|
||||
docker pull ${quote([dockerImage])} 2>&1 || {
|
||||
echo "❌ Pulling image failed";
|
||||
exit 1;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
findSSHKeyById,
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server/services/ssh-key";
|
||||
import { quote } from "shell-quote";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
interface CloneGitRepository {
|
||||
@@ -61,7 +62,7 @@ export const cloneGitRepository = async ({
|
||||
}
|
||||
command += `rm -rf ${outputPath};`;
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
command += `echo "Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅";`;
|
||||
command += `echo ${quote([`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`])};`;
|
||||
|
||||
if (customGitSSHKeyId) {
|
||||
await updateSSHKeyById({
|
||||
@@ -78,8 +79,8 @@ export const cloneGitRepository = async ({
|
||||
command += "chmod 600 /tmp/id_rsa;";
|
||||
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
|
||||
}
|
||||
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then
|
||||
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}";
|
||||
command += `if ! git clone --branch ${quote([String(customGitBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${quote([String(customGitUrl ?? "")])} ${quote([String(outputPath ?? "")])}; then
|
||||
echo ${quote([`❌ [ERROR] Fail to clone the repository ${customGitUrl}`])};
|
||||
exit 1;
|
||||
fi
|
||||
`;
|
||||
@@ -114,7 +115,7 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
|
||||
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
|
||||
// it, and its exit code must not abort the clone under `set -e`. The clone's
|
||||
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
|
||||
return `ssh-keyscan -p ${port} ${domain} >> ${knownHostsPath} || true;`;
|
||||
return `ssh-keyscan -p ${Number(port)} ${quote([String(domain ?? "")])} >> ${knownHostsPath} || true;`;
|
||||
};
|
||||
const sanitizeRepoPathSSH = (input: string) => {
|
||||
const SSH_PATH_RE = new RegExp(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@dokploy/server/services/gitea";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
export const getErrorCloneRequirements = (entity: {
|
||||
giteaRepository?: string | null;
|
||||
@@ -176,8 +177,8 @@ export const cloneGiteaRepository = async ({
|
||||
giteaRepository!,
|
||||
);
|
||||
|
||||
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
|
||||
command += `git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,8 +6,71 @@ import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Octokit } from "octokit";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const DEFAULT_GITHUB_URL = "https://github.com";
|
||||
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
|
||||
|
||||
export const parseGithubBaseUrl = (
|
||||
githubUrl?: string | null,
|
||||
): { url: string } | { error: string } => {
|
||||
const raw = githubUrl?.trim();
|
||||
if (!raw) {
|
||||
return { url: DEFAULT_GITHUB_URL };
|
||||
}
|
||||
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
|
||||
? raw
|
||||
: `https://${raw}`;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withScheme);
|
||||
} catch {
|
||||
return { error: `"${raw}" is not a valid URL` };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
return { error: "Only https is supported for GitHub instances" };
|
||||
}
|
||||
|
||||
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
|
||||
// would slip past the checks below.
|
||||
if (parsed.hostname.endsWith(".")) {
|
||||
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||
}
|
||||
|
||||
const { hostname } = parsed;
|
||||
|
||||
if (!hostname.includes(".")) {
|
||||
return { error: `"${hostname}" is not a fully qualified hostname` };
|
||||
}
|
||||
|
||||
return { url: `${parsed.protocol}//${parsed.host}` };
|
||||
};
|
||||
|
||||
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
|
||||
const result = parseGithubBaseUrl(githubUrl);
|
||||
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
|
||||
};
|
||||
|
||||
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
|
||||
const normalized = normalizeGithubUrl(githubUrl);
|
||||
const { protocol, host } = new URL(normalized);
|
||||
|
||||
if (host === "github.com" || host === "www.github.com") {
|
||||
return DEFAULT_GITHUB_API_URL;
|
||||
}
|
||||
|
||||
// Data residency tenants keep the api. prefix; GHES does not have one.
|
||||
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
|
||||
return `${protocol}//api.${host}`;
|
||||
}
|
||||
|
||||
return `${protocol}//${host}/api/v3`;
|
||||
};
|
||||
|
||||
export const authGithub = (githubProvider: Github): Octokit => {
|
||||
if (!haveGithubRequirements(githubProvider)) {
|
||||
throw new TRPCError({
|
||||
@@ -23,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
|
||||
privateKey: githubProvider?.githubPrivateKey || "",
|
||||
installationId: githubProvider?.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
|
||||
});
|
||||
|
||||
return octokit;
|
||||
@@ -161,13 +225,14 @@ export const cloneGithubRepository = async ({
|
||||
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
|
||||
const octokit = authGithub(githubProvider);
|
||||
const token = await getGithubToken(octokit);
|
||||
const repoclone = `github.com/${owner}/${repository}.git`;
|
||||
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
|
||||
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
|
||||
command += `rm -rf ${outputPath};`;
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
|
||||
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
|
||||
|
||||
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
|
||||
command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
|
||||
return command;
|
||||
};
|
||||
@@ -186,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
|
||||
privateKey: githubProvider.githubPrivateKey,
|
||||
installationId: githubProvider.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||
});
|
||||
|
||||
const repositories = (await octokit.paginate(
|
||||
@@ -212,6 +278,7 @@ export const getGithubBranches = async (
|
||||
privateKey: githubProvider.githubPrivateKey,
|
||||
installationId: githubProvider.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||
});
|
||||
|
||||
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@dokploy/server/services/gitlab";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const refreshGitlabToken = async (gitlabProviderId: string) => {
|
||||
@@ -151,8 +152,8 @@ export const cloneGitlabRepository = async ({
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
|
||||
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
|
||||
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
|
||||
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -26,10 +27,10 @@ export const restoreComposeBackup = async (
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
if (backupInput.metadata?.mongo) {
|
||||
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
}
|
||||
|
||||
let credentials: DatabaseCredentials = {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreLibsqlBackup = async (
|
||||
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
|
||||
const containerSearch = getServiceContainerCommand(appName);
|
||||
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreMariadbBackup = async (
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -18,7 +19,7 @@ export const restoreMongoBackup = async (
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreMySqlBackup = async (
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -20,7 +21,7 @@ export const restorePostgresBackup = async (
|
||||
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { quote } from "shell-quote";
|
||||
import {
|
||||
getComposeContainerCommand,
|
||||
getServiceContainerCommand,
|
||||
} from "../backups/utils";
|
||||
|
||||
// User-controlled values are passed to the container via `docker exec -e` and
|
||||
// read as "$VAR" inside a single-quoted inner script, so they never enter the
|
||||
// inner command text. See the matching note in backups/utils.ts.
|
||||
export const getPostgresRestoreCommand = (
|
||||
database: string,
|
||||
databaseUser: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID sh -c 'pg_restore -U "$DB_USER" -d "$DB_NAME" -O --clean --if-exists'`;
|
||||
};
|
||||
|
||||
export const getMariadbRestoreCommand = (
|
||||
@@ -15,14 +19,14 @@ export const getMariadbRestoreCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mariadb -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"'`;
|
||||
};
|
||||
|
||||
export const getMysqlRestoreCommand = (
|
||||
database: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mysql -u root -p"$DB_PASS" "$DB_NAME"'`;
|
||||
};
|
||||
|
||||
export const getMongoRestoreCommand = (
|
||||
@@ -30,7 +34,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 --drop"`;
|
||||
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mongorestore --username "$DB_USER" --password "$DB_PASS" --authenticationDatabase admin --db "$DB_NAME" --archive --drop'`;
|
||||
};
|
||||
|
||||
export const getComposeSearchCommand = (
|
||||
@@ -88,8 +92,8 @@ rm -rf ${tempDir} && \
|
||||
mkdir -p ${tempDir} && \
|
||||
${rcloneCommand} ${tempDir} && \
|
||||
cd ${tempDir} && \
|
||||
gunzip -f "${fileName}" && \
|
||||
${restoreCommand} < "${decompressedName}" && \
|
||||
gunzip -f ${quote([fileName])} && \
|
||||
${restoreCommand} < ${quote([decompressedName])} && \
|
||||
rm -rf ${tempDir}
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { quote } from "shell-quote";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
|
||||
@@ -35,7 +36,7 @@ export const restoreWebServerBackup = async (
|
||||
// Download backup from S3
|
||||
emit("Downloading backup from S3...");
|
||||
await execAsync(
|
||||
`rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`,
|
||||
`rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${tempDir}/${backupFile}`])}`,
|
||||
);
|
||||
|
||||
// List files before extraction
|
||||
@@ -45,7 +46,9 @@ export const restoreWebServerBackup = async (
|
||||
|
||||
// Extract backup
|
||||
emit("Extracting backup...");
|
||||
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
|
||||
await execAsync(
|
||||
`cd ${quote([tempDir])} && unzip ${quote([backupFile])} > /dev/null 2>&1`,
|
||||
);
|
||||
|
||||
// Restore filesystem first
|
||||
emit("Restoring filesystem...");
|
||||
|
||||
@@ -9,8 +9,12 @@ export const initSchedules = async () => {
|
||||
where: eq(schedules.enabled, true),
|
||||
with: {
|
||||
server: true,
|
||||
application: true,
|
||||
compose: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, serverId: true },
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, serverId: true },
|
||||
},
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findScheduleById } from "@dokploy/server/services/schedule";
|
||||
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
|
||||
import { quote } from "shell-quote";
|
||||
import { getComposeContainer, getServiceContainer } from "../docker/utils";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { spawnAsync } from "../process/spawnAsync";
|
||||
@@ -54,78 +55,97 @@ export const runCommand = async (scheduleId: string) => {
|
||||
description: "Schedule",
|
||||
});
|
||||
|
||||
if (scheduleType === "application" || scheduleType === "compose") {
|
||||
let containerId = "";
|
||||
let serverId = "";
|
||||
if (scheduleType === "application" && application) {
|
||||
const container = await getServiceContainer(
|
||||
application.appName,
|
||||
application.serverId,
|
||||
);
|
||||
containerId = container?.Id || "";
|
||||
serverId = application.serverId || "";
|
||||
}
|
||||
if (scheduleType === "compose" && compose) {
|
||||
const container = await getComposeContainer(compose, serviceName || "");
|
||||
containerId = container?.Id || "";
|
||||
serverId = compose.serverId || "";
|
||||
}
|
||||
try {
|
||||
if (scheduleType === "application" || scheduleType === "compose") {
|
||||
let containerId = "";
|
||||
let serverId = "";
|
||||
if (scheduleType === "application" && application) {
|
||||
const container = await getServiceContainer(
|
||||
application.appName,
|
||||
application.serverId,
|
||||
);
|
||||
containerId = container?.Id || "";
|
||||
serverId = application.serverId || "";
|
||||
}
|
||||
if (scheduleType === "compose" && compose) {
|
||||
const container = await getComposeContainer(compose, serviceName || "");
|
||||
containerId = container?.Id || "";
|
||||
serverId = compose.serverId || "";
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
try {
|
||||
if (!containerId) {
|
||||
const target =
|
||||
scheduleType === "compose"
|
||||
? `service '${serviceName}' of compose '${compose?.name}'`
|
||||
: `application '${application?.appName}'`;
|
||||
const message = `Container not found for ${target}, make sure the service is running`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo ${quote([`❌ ${message}`])} >> ${quote([deployment.logPath])}`,
|
||||
);
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, {
|
||||
flags: "a",
|
||||
});
|
||||
writeStream.write(`❌ ${message}\n`);
|
||||
writeStream.end();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`
|
||||
set -e
|
||||
echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath};
|
||||
docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
echo "Running scheduled command" >> ${quote([deployment.logPath])};
|
||||
docker exec ${quote([containerId])} ${quote([shellType])} -c ${quote([command])} >> ${quote([deployment.logPath])} 2>> ${quote([deployment.logPath])} || {
|
||||
echo "❌ Command failed" >> ${quote([deployment.logPath])};
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||
echo "✅ Command executed successfully" >> ${quote([deployment.logPath])};
|
||||
`,
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, {
|
||||
flags: "a",
|
||||
});
|
||||
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
writeStream.write(
|
||||
"This feature is not available in the cloud version.",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
return { ...deployment, status: "error" as const };
|
||||
}
|
||||
writeStream.write(
|
||||
"This feature is not available in the cloud version.",
|
||||
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||
);
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
writeStream.end();
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
writeStream.write(
|
||||
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||
);
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (scheduleType === "dokploy-server") {
|
||||
try {
|
||||
} else if (scheduleType === "dokploy-server") {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
const { SCHEDULES_PATH } = paths();
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
@@ -150,18 +170,13 @@ export const runCommand = async (scheduleId: string) => {
|
||||
cwd: fullPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else if (scheduleType === "server") {
|
||||
try {
|
||||
} else if (scheduleType === "server") {
|
||||
const { SCHEDULES_PATH } = paths(true);
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
const command = `
|
||||
set -e
|
||||
set -euo pipefail
|
||||
echo "Running script" >> ${deployment.logPath};
|
||||
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
|
||||
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
exit 1;
|
||||
}
|
||||
@@ -176,10 +191,11 @@ export const runCommand = async (scheduleId: string) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
return { ...deployment, status: "done" as const };
|
||||
} catch {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
return { ...deployment, status: "error" as const };
|
||||
}
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { encodeBase64 } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -57,7 +58,7 @@ 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}`;
|
||||
const command = `rm -f ${quote([configPath])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
@@ -76,7 +77,7 @@ export const removeTraefikConfigRemote = async (
|
||||
try {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
await execAsyncRemote(serverId, `rm -f ${configPath}`);
|
||||
await execAsyncRemote(serverId, `rm -f ${quote([configPath])}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error removing remote traefik config for ${appName}:`,
|
||||
@@ -106,7 +107,10 @@ export const loadOrCreateConfigRemote = async (
|
||||
const fileConfig: FileConfig = { http: { routers: {}, services: {} } };
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
try {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
|
||||
if (!stdout) return fileConfig;
|
||||
|
||||
@@ -133,7 +137,10 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
try {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
if (!stdout) return null;
|
||||
return stdout;
|
||||
} catch {
|
||||
@@ -189,7 +196,10 @@ export const readConfigInPath = async (pathFile: string, serverId?: string) => {
|
||||
const configPath = path.join(pathFile);
|
||||
|
||||
if (serverId) {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
if (!stdout) return null;
|
||||
return stdout;
|
||||
}
|
||||
@@ -221,7 +231,7 @@ export const writeConfigRemote = async (
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > "${configPath}"`,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
@@ -239,7 +249,7 @@ export const writeTraefikConfigInPath = async (
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > "${configPath}"`,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(configPath, traefikConfig, "utf8");
|
||||
@@ -272,7 +282,11 @@ export const writeTraefikConfigRemote = async (
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
const yamlStr = stringify(traefikConfig);
|
||||
await execAsyncRemote(serverId, `echo '${yamlStr}' > ${configPath}`);
|
||||
const encoded = encodeBase64(yamlStr);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
GetSecretValueCommand,
|
||||
ListSecretsCommand,
|
||||
SecretsManagerClient,
|
||||
} from "@aws-sdk/client-secrets-manager";
|
||||
import type { awsVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import type { VaultClient } from "./types";
|
||||
|
||||
type AwsConfig = z.infer<typeof awsVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
if (ref.startsWith("arn:")) {
|
||||
throw new Error(
|
||||
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
|
||||
);
|
||||
}
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return { secretId: ref, field: null };
|
||||
}
|
||||
return {
|
||||
secretId: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const createClient = (config: AwsConfig) =>
|
||||
new SecretsManagerClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
...(config.endpoint && { endpoint: config.endpoint }),
|
||||
});
|
||||
|
||||
export const awsClient: VaultClient<AwsConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const client = createClient(config);
|
||||
const secretIds = [...new Set(refs.map((ref) => parseRef(ref).secretId))];
|
||||
|
||||
const secretStrings = new Map<string, string>();
|
||||
await Promise.all(
|
||||
secretIds.map(async (secretId) => {
|
||||
const response = await client.send(
|
||||
new GetSecretValueCommand({ SecretId: secretId }),
|
||||
);
|
||||
if (response.SecretString === undefined) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" has no string value (binary secrets are not supported)`,
|
||||
);
|
||||
}
|
||||
secretStrings.set(secretId, response.SecretString);
|
||||
}),
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
const { secretId, field } = parseRef(ref);
|
||||
const secretString = secretStrings.get(secretId) as string;
|
||||
if (field === null) {
|
||||
result[ref] = secretString;
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(secretString);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" is not JSON, cannot extract field "${field}"`,
|
||||
);
|
||||
}
|
||||
const value = parsed[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: field "${field}" not found in secret "${secretId}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const client = createClient(config);
|
||||
await client.send(new ListSecretsCommand({ MaxResults: 1 }));
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const client = createClient(config);
|
||||
const names: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListSecretsCommand({ MaxResults: 100, NextToken: nextToken }),
|
||||
);
|
||||
for (const secret of response.SecretList ?? []) {
|
||||
if (secret.Name) {
|
||||
names.push(secret.Name);
|
||||
}
|
||||
}
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken && names.length < 500);
|
||||
return names;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { azureVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type AzureConfig = z.infer<typeof azureVaultConfigSchema>;
|
||||
|
||||
const API_VERSION = "7.4";
|
||||
|
||||
const baseUrl = (config: AzureConfig) => config.vaultUri.replace(/\/+$/, "");
|
||||
|
||||
const getAccessToken = async (config: AzureConfig) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: "https://vault.azure.net/.default",
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error_description?: string };
|
||||
detail = (body.error_description ?? "").split("\n")[0] ?? "";
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Azure Key Vault: authentication failed (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string };
|
||||
if (!data.access_token) {
|
||||
throw new Error("Azure Key Vault: no access token returned");
|
||||
}
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
const readSecret = async (config: AzureConfig, token: string, name: string) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" not found`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: failed to read secret "${name}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value?: string };
|
||||
if (data.value === undefined) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" has no value`);
|
||||
}
|
||||
return data.value;
|
||||
};
|
||||
|
||||
export const azureClient: VaultClient<AzureConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const token = await getAccessToken(config);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
refs.map(async (ref) => {
|
||||
result[ref] = await readSecret(config, token, ref);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: cannot list secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const names: string[] = [];
|
||||
let url: string | null =
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
|
||||
|
||||
while (url && names.length < 200) {
|
||||
const response = await vaultFetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
break;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
value?: { id: string }[];
|
||||
nextLink?: string | null;
|
||||
};
|
||||
for (const item of data.value ?? []) {
|
||||
const name = item.id.split("/").pop();
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
url = data.nextLink ?? null;
|
||||
}
|
||||
return names.slice(0, 200);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { dopplerVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type DopplerConfig = z.infer<typeof dopplerVaultConfigSchema>;
|
||||
|
||||
const downloadUrl = (config: DopplerConfig) => {
|
||||
const params = new URLSearchParams({ format: "json" });
|
||||
if (config.project) {
|
||||
params.set("project", config.project);
|
||||
}
|
||||
if (config.config) {
|
||||
params.set("config", config.config);
|
||||
}
|
||||
return `https://api.doppler.com/v3/configs/config/secrets/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const downloadSecrets = async (config: DopplerConfig) => {
|
||||
const response = await vaultFetch(downloadUrl(config), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.serviceToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { messages?: string[] };
|
||||
detail = (body.messages ?? []).join(", ");
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Doppler: failed to fetch secrets (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, string>;
|
||||
};
|
||||
|
||||
export const dopplerClient: VaultClient<DopplerConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(`Doppler: secret "${ref}" not found in this config`);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await downloadSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { hashicorpVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type HashicorpConfig = z.infer<typeof hashicorpVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex <= 0 || separatorIndex === ref.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid HashiCorp Vault reference "${ref}": expected format <path>:<field> (e.g. myapp/prod:DB_PASSWORD)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const buildHeaders = (config: HashicorpConfig) => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Vault-Token": config.token,
|
||||
};
|
||||
if (config.namespace) {
|
||||
headers["X-Vault-Namespace"] = config.namespace;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const baseUrl = (config: HashicorpConfig) => config.url.replace(/\/+$/, "");
|
||||
|
||||
const encodePath = (path: string) =>
|
||||
path.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const readSecret = async (config: HashicorpConfig, path: string) => {
|
||||
const url = `${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/data/${encodePath(path)}`;
|
||||
const response = await vaultFetch(url, { headers: buildHeaders(config) });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: failed to read secret at "${path}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { data?: Record<string, unknown> };
|
||||
};
|
||||
return body.data?.data ?? {};
|
||||
};
|
||||
|
||||
export const hashicorpClient: VaultClient<HashicorpConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const ref of refs) {
|
||||
const { path } = parseRef(ref);
|
||||
byPath.set(path, [...(byPath.get(path) ?? []), ref]);
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
[...byPath.entries()].map(async ([path, pathRefs]) => {
|
||||
const data = await readSecret(config, path);
|
||||
for (const ref of pathRefs) {
|
||||
const { field } = parseRef(ref);
|
||||
const value = data[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: field "${field}" not found in secret "${path}"`,
|
||||
);
|
||||
}
|
||||
result[ref] =
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/auth/token/lookup-self`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: token validation failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_ENTRIES = 200;
|
||||
const names: string[] = [];
|
||||
|
||||
const listKeys = async (path: string) => {
|
||||
const cleanPath = path.replace(/\/+$/, "");
|
||||
const suffix = cleanPath ? `/${encodePath(cleanPath)}` : "";
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/metadata${suffix}?list=true`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = (await response.json()) as { data?: { keys?: string[] } };
|
||||
return body.data?.keys ?? [];
|
||||
};
|
||||
|
||||
const walk = async (path: string, depth: number) => {
|
||||
if (depth > MAX_DEPTH || names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
for (const key of await listKeys(path)) {
|
||||
if (names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const fullPath = `${path}${key}`;
|
||||
if (key.endsWith("/")) {
|
||||
await walk(fullPath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
const fields = await readSecret(config, fullPath).catch(
|
||||
() => ({}) as Record<string, unknown>,
|
||||
);
|
||||
const fieldNames = Object.keys(fields);
|
||||
if (fieldNames.length === 0) {
|
||||
names.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
for (const field of fieldNames) {
|
||||
names.push(`${fullPath}:${field}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk("", 0);
|
||||
return names.slice(0, MAX_ENTRIES);
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
import type { infisicalVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type InfisicalConfig = z.infer<typeof infisicalVaultConfigSchema>;
|
||||
|
||||
const baseUrl = (config: InfisicalConfig) => config.siteUrl.replace(/\/+$/, "");
|
||||
|
||||
const login = async (config: InfisicalConfig) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v1/auth/universal-auth/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: authentication failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { accessToken?: string };
|
||||
if (!body.accessToken) {
|
||||
throw new Error("Infisical: no access token returned");
|
||||
}
|
||||
return body.accessToken;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
const accessToken = await login(config);
|
||||
const params = new URLSearchParams({
|
||||
workspaceId: config.projectId,
|
||||
environment: config.environmentSlug,
|
||||
secretPath: config.secretPath,
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: failed to fetch secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
secrets?: { secretKey: string; secretValue: string }[];
|
||||
};
|
||||
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const secret of body.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const infisicalClient: VaultClient<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await fetchSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { VaultProviderConfig } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface VaultClient<
|
||||
C extends VaultProviderConfig = VaultProviderConfig,
|
||||
> {
|
||||
getSecrets(config: C, refs: string[]): Promise<Record<string, string>>;
|
||||
testConnection(config: C): Promise<void>;
|
||||
listSecretNames?(config: C): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const VAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const vaultFetch = async (url: string, init: RequestInit = {}) => {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
@@ -13,8 +13,8 @@ export const initVolumeBackupsCronJobs = async () => {
|
||||
const volumeBackupsResult = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups.enabled, true),
|
||||
with: {
|
||||
application: true,
|
||||
compose: true,
|
||||
application: { columns: { applicationId: true } },
|
||||
compose: { columns: { composeId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { quote } from "shell-quote";
|
||||
import {
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
@@ -23,7 +24,7 @@ export const restoreVolume = async (
|
||||
const backupPath = `${bucketPath}/${backupFileName}`;
|
||||
|
||||
// Command to download backup file from S3
|
||||
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${volumeBackupPath}/${backupFileName}"`;
|
||||
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${volumeBackupPath}/${backupFileName}`])}`;
|
||||
|
||||
// Base restore command that creates the volume and restores data
|
||||
const baseRestoreCommand = `
|
||||
@@ -40,7 +41,7 @@ export const restoreVolume = async (
|
||||
-v ${volumeName}:/volume_data \
|
||||
-v ${volumeBackupPath}:/backup \
|
||||
ubuntu \
|
||||
bash -c "cd /volume_data && tar xvf /backup/${backupFileName} ."
|
||||
bash -c "cd /volume_data && tar xvf /backup/${quote([backupFileName])} ."
|
||||
echo "Volume restore completed ✅"
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user