This commit is contained in:
user
2026-01-30 20:23:32 +03:00
parent 744ebab15a
commit ce9ba60902
21 changed files with 24797 additions and 20005 deletions

View File

@@ -32,5 +32,6 @@ export const paths = (isServer = false) => {
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
PATCH_REPOS_PATH: `${BASE_PATH}/patch-repos`,
};
};

View File

@@ -19,6 +19,7 @@ import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { patch } from "./patch";
import { ports } from "./port";
import { previewDeployments } from "./preview-deployments";
import { redirects } from "./redirects";
@@ -286,6 +287,7 @@ export const applicationsRelations = relations(
references: [registry.registryId],
relationName: "applicationRollbackRegistry",
}),
patches: many(patch),
}),
);

View File

@@ -12,6 +12,7 @@ import { gitea } from "./gitea";
import { github } from "./github";
import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { patch } from "./patch";
import { schedules } from "./schedule";
import { server } from "./server";
import { applicationStatus, triggerType } from "./shared";
@@ -143,6 +144,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
}),
backups: many(backups),
schedules: many(schedules),
patches: many(patch),
}));
const createSchema = createInsertSchema(compose, {

View File

@@ -18,6 +18,7 @@ export * from "./mongo";
export * from "./mount";
export * from "./mysql";
export * from "./notification";
export * from "./patch";
export * from "./port";
export * from "./postgres";
export * from "./preview-deployments";

View File

@@ -0,0 +1,95 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text, unique } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
export const patch = pgTable(
"patch",
{
patchId: text("patchId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
filePath: text("filePath").notNull(),
enabled: boolean("enabled").notNull().default(true),
content: text("content").notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt").$defaultFn(() => new Date().toISOString()),
// Relations - one of these must be set
applicationId: text("applicationId").references(
() => applications.applicationId,
{ onDelete: "cascade" },
),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
},
(table) => [
// Unique constraint: one patch per file per application/compose
unique("patch_filepath_application_unique").on(
table.filePath,
table.applicationId,
),
unique("patch_filepath_compose_unique").on(table.filePath, table.composeId),
],
);
export const patchRelations = relations(patch, ({ one }) => ({
application: one(applications, {
fields: [patch.applicationId],
references: [applications.applicationId],
}),
compose: one(compose, {
fields: [patch.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(patch, {
filePath: z.string().min(1),
content: z.string(),
enabled: z.boolean().optional(),
applicationId: z.string().optional(),
composeId: z.string().optional(),
});
export const apiCreatePatch = createSchema.pick({
filePath: true,
content: true,
enabled: true,
applicationId: true,
composeId: true,
});
export const apiFindPatch = z.object({
patchId: z.string().min(1),
});
export const apiFindPatchesByApplicationId = z.object({
applicationId: z.string().min(1),
});
export const apiFindPatchesByComposeId = z.object({
composeId: z.string().min(1),
});
export const apiUpdatePatch = createSchema
.partial()
.extend({
patchId: z.string().min(1),
})
.omit({ applicationId: true, composeId: true });
export const apiDeletePatch = z.object({
patchId: z.string().min(1),
});
export const apiTogglePatchEnabled = z.object({
patchId: z.string().min(1),
enabled: z.boolean(),
});

View File

@@ -27,6 +27,8 @@ export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/notification";
export * from "./services/patch";
export * from "./services/patch-repo";
export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";

View File

@@ -44,6 +44,10 @@ import {
issueCommentExists,
updateIssueComment,
} from "./github";
import {
findPatchesByApplicationId,
generateApplyPatchesCommand,
} from "./patch";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
@@ -202,6 +206,20 @@ export const deployApplication = async ({
command += await buildRemoteDocker(application);
}
// Apply patches after cloning (for non-docker sources only)
if (application.sourceType !== "docker") {
const patches = await findPatchesByApplicationId(application.applicationId);
const enabledPatches = patches.filter(p => p.enabled);
if (enabledPatches.length > 0) {
command += generateApplyPatchesCommand({
appName: application.appName,
type: "application",
serverId,
patches: enabledPatches,
});
}
}
command += await getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;

View File

@@ -40,6 +40,10 @@ import {
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
import {
findPatchesByComposeId,
generateApplyPatchesCommand,
} from "./patch";
import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect;
@@ -248,6 +252,26 @@ export const deployCompose = async ({
await execAsync(commandWithLog);
}
// Apply patches after cloning (for non-raw sources only)
if (compose.sourceType !== "raw") {
const patches = await findPatchesByComposeId(compose.composeId);
const enabledPatches = patches.filter(p => p.enabled);
if (enabledPatches.length > 0) {
const patchCommand = generateApplyPatchesCommand({
appName: compose.appName,
type: "compose",
serverId: compose.serverId,
patches: enabledPatches,
});
const patchCommandWithLog = `(${patchCommand}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, patchCommandWithLog);
} else {
await execAsync(patchCommandWithLog);
}
}
}
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;

View File

@@ -0,0 +1,308 @@
import path, { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { findSSHKeyById } from "@dokploy/server/services/ssh-key";
import { TRPCError } from "@trpc/server";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
interface PatchRepoConfig {
appName: string;
type: "application" | "compose";
gitUrl: string;
gitBranch: string;
sshKeyId?: string | null;
serverId?: string | null;
}
/**
* Ensure patch repo exists and is up-to-date
* Returns path to the repo
*/
export const ensurePatchRepo = async ({
appName,
type,
gitUrl,
gitBranch,
sshKeyId,
serverId,
}: PatchRepoConfig): Promise<string> => {
const { PATCH_REPOS_PATH, SSH_PATH } = paths(!!serverId);
const repoPath = join(PATCH_REPOS_PATH, type, appName);
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
// Check if repo exists
const checkCommand = `test -d "${repoPath}/.git" && echo "exists" || echo "not_exists"`;
let exists = false;
if (serverId) {
const result = await execAsyncRemote(serverId, checkCommand);
exists = result.stdout.trim() === "exists";
} else {
const result = await execAsync(checkCommand);
exists = result.stdout.trim() === "exists";
}
// Setup SSH if needed
let sshSetup = "";
if (sshKeyId) {
const sshKey = await findSSHKeyById(sshKeyId);
const temporalKeyPath = "/tmp/patch_repo_id_rsa";
sshSetup = `
echo "${sshKey.privateKey}" > ${temporalKeyPath};
chmod 600 ${temporalKeyPath};
export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new";
`;
}
if (!exists) {
// Clone the repo
const cloneCommand = `
set -e;
${sshSetup}
mkdir -p "${repoPath}";
git clone --branch ${gitBranch} --progress "${gitUrl}" "${repoPath}";
echo "Repository cloned successfully";
`;
try {
if (serverId) {
await execAsyncRemote(serverId, cloneCommand);
} else {
await execAsync(cloneCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to clone repository: ${error}`,
});
}
} else {
// Repo exists - check if on correct branch and update
const updateCommand = `
set -e;
cd "${repoPath}";
${sshSetup}
# Fetch all updates including tags
git fetch origin --tags --force
# Checkout the target (branch or tag) - this handles switching branches/tags
git checkout --force "${gitBranch}"
# If it's a branch that corresponds to a remote branch, hard reset to match remote
# This ensures we pull the latest changes for that branch.
# If it's a tag, we are already at the correct commit after checkout.
if git rev-parse --verify "origin/${gitBranch}" >/dev/null 2>&1; then
git reset --hard "origin/${gitBranch}"
fi
echo "Updated repository to ${gitBranch}"
`;
try {
if (serverId) {
await execAsyncRemote(serverId, updateCommand);
} else {
await execAsync(updateCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to update repository: ${error}`,
});
}
}
return repoPath;
};
interface DirectoryEntry {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
}
/**
* Read directory tree of the patch repo
*/
export const readPatchRepoDirectory = async (
repoPath: string,
serverId?: string | null,
): Promise<DirectoryEntry[]> => {
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
let stdout: string;
try {
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to read repository: ${error}`,
});
}
const files = stdout.trim().split("\n").filter(Boolean);
// Build tree structure
const root: DirectoryEntry[] = [];
const dirMap = new Map<string, DirectoryEntry>();
for (const filePath of files) {
const parts = filePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
const isFile = i === parts.length - 1;
const parentPath = currentPath;
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!dirMap.has(currentPath)) {
const entry: DirectoryEntry = {
name: part,
path: currentPath,
type: isFile ? "file" : "directory",
children: isFile ? undefined : [],
};
dirMap.set(currentPath, entry);
if (parentPath) {
const parent = dirMap.get(parentPath);
parent?.children?.push(entry);
} else {
root.push(entry);
}
}
}
}
return root;
};
interface ReadFileResult {
content: string;
patchError?: boolean;
patchErrorMessage?: string;
}
/**
* Read file content from patch repo, optionally with patch applied
*/
export const readPatchRepoFile = async (
repoPath: string,
filePath: string,
patchContent?: string,
serverId?: string | null,
): Promise<ReadFileResult> => {
const fullPath = join(repoPath, filePath);
// Read original file
const command = `cat "${fullPath}" 2>/dev/null || echo "__FILE_NOT_FOUND__"`;
let content: string;
try {
if (serverId) {
const result = await execAsyncRemote(serverId, command);
content = result.stdout;
} else {
const result = await execAsync(command);
content = result.stdout;
}
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: `File not found: ${filePath}`,
});
}
if (content.trim() === "__FILE_NOT_FOUND__") {
throw new TRPCError({
code: "NOT_FOUND",
message: `File not found: ${filePath}`,
});
}
// If no patch, return original content
if (!patchContent) {
return { content };
}
// Try to apply patch
const tempDir = `/tmp/patch_apply_${Date.now()}`;
const encodedContent = Buffer.from(content).toString("base64");
const encodedPatch = Buffer.from(patchContent).toString("base64");
// We need to recreate the file structure for git apply to work
// git diff usually uses paths relative to repo root
const applyCommand = `
set -e;
mkdir -p "${tempDir}";
cd "${tempDir}";
git init -q;
# Create file with correct path
mkdir -p "$(dirname "${filePath}")";
echo "${encodedContent}" | base64 -d > "${filePath}";
# Save patch
echo "${encodedPatch}" | base64 -d > "patch.diff";
# Apply patch
git apply --ignore-space-change --ignore-whitespace patch.diff;
# Read result
cat "${filePath}";
rm -rf "${tempDir}";
`;
try {
let patchedContent: string;
if (serverId) {
const result = await execAsyncRemote(serverId, applyCommand);
patchedContent = result.stdout;
} else {
const result = await execAsync(applyCommand);
patchedContent = result.stdout;
}
return { content: patchedContent };
} catch (error) {
// Patch failed - return original content with error
const cleanupCommand = `rm -rf "${tempDir}" 2>/dev/null || true`;
try {
if (serverId) {
await execAsyncRemote(serverId, cleanupCommand);
} else {
await execAsync(cleanupCommand);
}
} catch {
// Ignore cleanup errors
}
return {
content,
patchError: true,
patchErrorMessage: `Failed to apply patch: ${error}`,
};
}
};
/**
* Clean all patch repos
*/
export const cleanPatchRepos = async (serverId?: string | null): Promise<void> => {
const { PATCH_REPOS_PATH } = paths(!!serverId);
const command = `rm -rf "${PATCH_REPOS_PATH}"/* 2>/dev/null || true`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};

View File

@@ -0,0 +1,295 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
type apiCreatePatch,
patch,
} from "@dokploy/server/db/schema";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { and, eq, isNotNull } from "drizzle-orm";
export type Patch = typeof patch.$inferSelect;
// CRUD Operations
export const createPatch = async (input: typeof apiCreatePatch._type) => {
if (!input.applicationId && !input.composeId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
const newPatch = await db
.insert(patch)
.values({
...input,
content: input.content.endsWith("\n")
? input.content
: `${input.content}\n`,
})
.returning()
.then((value) => value[0]);
if (!newPatch) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the patch",
});
}
return newPatch;
};
export const findPatchById = async (patchId: string) => {
const result = await db.query.patch.findFirst({
where: eq(patch.patchId, patchId),
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Patch not found",
});
}
return result;
};
export const findPatchesByApplicationId = async (applicationId: string) => {
return await db.query.patch.findMany({
where: and(
eq(patch.applicationId, applicationId),
isNotNull(patch.applicationId),
),
orderBy: (patch, { asc }) => [asc(patch.filePath)],
});
};
export const findPatchesByComposeId = async (composeId: string) => {
return await db.query.patch.findMany({
where: and(eq(patch.composeId, composeId), isNotNull(patch.composeId)),
orderBy: (patch, { asc }) => [asc(patch.filePath)],
});
};
export const findPatchByFilePath = async (
filePath: string,
applicationId?: string,
composeId?: string,
) => {
if (applicationId) {
return await db.query.patch.findFirst({
where: and(
eq(patch.filePath, filePath),
eq(patch.applicationId, applicationId),
),
});
}
if (composeId) {
return await db.query.patch.findFirst({
where: and(eq(patch.filePath, filePath), eq(patch.composeId, composeId)),
});
}
return null;
};
export const updatePatch = async (
patchId: string,
data: Partial<Patch>,
) => {
const result = await db
.update(patch)
.set({
...data,
...(data.content && {
content: data.content.endsWith("\n")
? data.content
: `${data.content}\n`,
}),
updatedAt: new Date().toISOString(),
})
.where(eq(patch.patchId, patchId))
.returning();
return result[0];
};
export const deletePatch = async (patchId: string) => {
const result = await db
.delete(patch)
.where(eq(patch.patchId, patchId))
.returning();
return result[0];
};
// Patch Application Functions
interface ApplyPatchesOptions {
appName: string;
type: "application" | "compose";
serverId: string | null;
patches: Patch[];
}
/**
* Generate shell commands to apply patches to cloned repository
* Uses git apply to apply unified diff patches
*/
export const generateApplyPatchesCommand = ({
appName,
type,
patches,
serverId,
}: ApplyPatchesOptions): string => {
if (patches.length === 0) {
return "";
}
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const codePath = join(basePath, appName, "code");
let command = `echo "Applying ${patches.length} patch(es)...";`;
for (const p of patches) {
// Create a temporary patch file and apply it
const patchFileName = `/tmp/patch_${p.patchId}.patch`;
// Escape content for shell - use base64 encoding
const encodedContent = Buffer.from(p.content).toString("base64");
command += `
echo "${encodedContent}" | base64 -d > ${patchFileName};
cd ${codePath} && git apply --whitespace=fix ${patchFileName} && echo "✅ Applied patch for: ${p.filePath}" || echo "⚠️ Warning: Failed to apply patch for: ${p.filePath}";
rm -f ${patchFileName};
`;
}
return command;
};
/**
* Apply patches during build process
*/
export const applyPatches = async ({
appName,
type,
serverId,
patches,
}: ApplyPatchesOptions): Promise<void> => {
const enabledPatches = patches.filter((p) => p.enabled);
if (enabledPatches.length === 0) {
return;
}
const command = generateApplyPatchesCommand({
appName,
type,
serverId,
patches: enabledPatches,
});
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
interface GeneratePatchOptions {
codePath: string;
filePath: string;
newContent: string;
serverId?: string | null;
}
/**
* Generate a patch from modified file content using git diff
*/
export const generatePatch = async ({
codePath,
filePath,
newContent,
serverId,
}: GeneratePatchOptions): Promise<string> => {
const fullPath = join(codePath, filePath);
// Write new content to the file
const encodedContent = Buffer.from(newContent).toString("base64");
const writeCommand = `echo "${encodedContent}" | base64 -d > "${fullPath}"`;
if (serverId) {
await execAsyncRemote(serverId, writeCommand);
} else {
await execAsync(writeCommand);
}
// Generate diff
const diffCommand = `cd "${codePath}" && git diff -- "${filePath}"`;
let diffResult: string;
if (serverId) {
const result = await execAsyncRemote(serverId, diffCommand);
diffResult = result.stdout;
} else {
const result = await execAsync(diffCommand);
diffResult = result.stdout;
}
// Reset the file to original state
const resetCommand = `cd "${codePath}" && git checkout -- "${filePath}"`;
if (serverId) {
await execAsyncRemote(serverId, resetCommand);
} else {
await execAsync(resetCommand);
}
return diffResult;
};
interface ApplyPatchToContentOptions {
originalContent: string;
patchContent: string;
}
/**
* Apply a patch to content in memory (for preview purposes)
* Returns the patched content or throws an error if patch fails
*/
export const applyPatchToContent = async ({
originalContent,
patchContent,
}: ApplyPatchToContentOptions): Promise<string> => {
// Create temp files and apply patch
const tempDir = "/tmp/patch_preview_" + Date.now();
const tempFile = `${tempDir}/file`;
const patchFile = `${tempDir}/patch.diff`;
const encodedOriginal = Buffer.from(originalContent).toString("base64");
const encodedPatch = Buffer.from(patchContent).toString("base64");
const command = `
mkdir -p "${tempDir}";
echo "${encodedOriginal}" | base64 -d > "${tempFile}";
echo "${encodedPatch}" | base64 -d > "${patchFile}";
cd "${tempDir}" && patch -p0 < "${patchFile}" 2>/dev/null;
cat "${tempFile}";
rm -rf "${tempDir}";
`;
try {
const result = await execAsync(command);
return result.stdout;
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to apply patch to content",
});
}
};