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

@@ -1,3 +1,4 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
@@ -8,6 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock constants to avoid load error
vi.mock("@dokploy/server/constants", () => ({
paths: () => ({
LOGS_PATH: "/tmp/dokploy-test-real/logs",
APPLICATIONS_PATH: "/tmp/dokploy-test-real/applications",
PATCH_REPOS_PATH: "/tmp/dokploy-test-real/patch-repos",
}),
IS_CLOUD: false,
docker: {},
}));
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
@@ -67,6 +79,16 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
vi.mock("@dokploy/server/services/patch", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@dokploy/server/services/patch")
>();
return {
...actual,
findPatchesByApplicationId: vi.fn().mockResolvedValue([]),
};
});
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
@@ -78,6 +100,11 @@ import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as patchService from "@dokploy/server/services/patch";
import { generatePatch } from "@dokploy/server/services/patch";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
@@ -474,6 +501,105 @@ describe(
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY apply patches from database during deployment",
async () => {
// 1. Setup local temporary git repo
const tempRepo = await mkdtemp(join(tmpdir(), "real-patch-repo-"));
// Helper for local git commands
const execLocal = async (cmd: string) => execAsync(cmd, { cwd: tempRepo });
await execLocal("git init");
await execLocal("git config user.email 'test@dokploy.com'");
await execLocal("git config user.name 'Dokploy Test'");
// Create a simple Dockerfile and server script
// We use a simple python server to verify output
await writeFile(join(tempRepo, "app.py"), "print('Original App')\n");
await writeFile(
join(tempRepo, "Dockerfile"),
"FROM python:3.9-slim\nCOPY app.py .\nCMD [\"python\", \"app.py\"]\n",
);
await execLocal("git add .");
await execLocal("git commit -m 'Initial commit'");
// Ensure master/main branch exists (git init might create master or main depending on config)
// We force create a branch named 'main' to be consistent
await execLocal("git checkout -b main || git checkout main");
// 2. Mock Application to use this local repo
const patchAppName = `real-patch-app-${Date.now()}`;
const patchApp = createMockApplication({
appName: patchAppName,
buildType: "dockerfile",
customGitUrl: `file://${tempRepo}`,
customGitBranch: "main",
dockerfile: "Dockerfile",
});
currentAppName = patchAppName;
allTestAppNames.push(patchAppName);
// Setup standard mocks
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
patchApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
patchApp as any,
);
// 3. Generate a patch
// We modify the file, generate patch, and then reset.
const newContent = "print('Patched App')\n";
const patchContent = await generatePatch({
codePath: tempRepo,
filePath: "app.py",
newContent,
serverId: null,
});
// 4. Mock patch service to return this patch
vi.mocked(patchService.findPatchesByApplicationId).mockResolvedValue([
{
patchId: "test-patch-1",
applicationId: "test-app-id",
composeId: null,
filePath: "app.py",
content: patchContent,
enabled: true,
createdAt: new Date().toISOString(),
} as any,
]);
console.log(`\n🚀 Testing deployment with patch: ${currentAppName}`);
// 5. Deploy
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Patch Test",
descriptionLog: "Testing patch application",
});
expect(result).toBe(true);
// 6. Verify Log contains "Applying patch"
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
// The implementation logs "Applying patch: ..."
expect(logContent).toContain("Applying patch");
expect(logContent).toContain("app.py");
console.log("✅ Verified patch execution logs");
// 7. Verify the deployed image contains the patched code
// We run the image and check output
const { stdout: runOutput } = await execAsync(
`docker run --rm ${patchAppName}`,
);
expect(runOutput.trim()).toBe("Patched App");
console.log("✅ Verified patched output:", runOutput.trim());
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -0,0 +1,106 @@
import { generatePatch } from "@dokploy/server/services/patch";
import { describe, expect, it, afterEach } from "vitest";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsyncLocal = promisify(exec);
describe("Patch System Integration", () => {
let tempDir: string;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
it("should generate a patch that can be successfully applied via git", async () => {
// Setup repo
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-"));
const fileName = "test.txt";
const filePath = join(tempDir, fileName);
await execAsyncLocal("git init", { cwd: tempDir });
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
// Original content
await writeFile(filePath, "line1\nline2\n");
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
// Generate patch (modify content)
const newContent = "line1\nline2\nline3\n";
const patchContent = await generatePatch({
codePath: tempDir,
filePath: fileName,
newContent,
serverId: null,
});
// Verify patch format
expect(patchContent.endsWith("\n")).toBe(true);
// Reset file (generatePatch does reset, but ensure it)
await execAsyncLocal("git checkout .", { cwd: tempDir });
const savedContent = await readFile(filePath, "utf-8");
expect(savedContent).toBe("line1\nline2\n");
// Apply patch verification
// We simulate what Deployment Service does: write patch to file and run git apply
const patchFile = join(tempDir, "changes.patch");
await writeFile(patchFile, patchContent);
try {
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
} catch (e: any) {
console.error("Git apply failed:", e.message);
console.log("Patch content:", JSON.stringify(patchContent));
throw e;
}
const appliedContent = await readFile(filePath, "utf-8");
expect(appliedContent).toBe(newContent);
});
it("should handle files created without trailing newline", async () => {
// Setup repo
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-noline-"));
const fileName = "noline.txt";
const filePath = join(tempDir, fileName);
await execAsyncLocal("git init", { cwd: tempDir });
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
// Original content WITHOUT newline
await writeFile(filePath, "line1");
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
// Generate patch
const newContent = "line1\nline2";
const patchContent = await generatePatch({
codePath: tempDir,
filePath: fileName,
newContent,
serverId: null,
});
// Verify patch format
expect(patchContent.endsWith("\n")).toBe(true);
// Apply patch
const patchFile = join(tempDir, "changes.patch");
await writeFile(patchFile, patchContent);
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
const appliedContent = await readFile(filePath, "utf-8");
expect(appliedContent).toBe(newContent);
});
});

View File

@@ -0,0 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -0,0 +1,235 @@
import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
interface Props {
applicationId?: string;
composeId?: string;
repoPath: string;
onClose: () => void;
}
type DirectoryEntry = {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
};
export const PatchEditor = ({
applicationId,
composeId,
repoPath,
onClose,
}: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [originalContent, setOriginalContent] = useState<string>("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isSaving, setIsSaving] = useState(false);
// Fetch directory tree
const { data: directories, isLoading: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ applicationId, composeId, repoPath },
{ enabled: !!repoPath },
);
// Save mutation
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
onSuccess: (result) => {
setIsSaving(false);
if (result.deleted) {
toast.success("No changes - patch removed");
} else {
toast.success("Patch saved");
}
setOriginalContent(fileContent);
},
onError: () => {
setIsSaving(false);
toast.error("Failed to save patch");
},
});
// Read file content when selected
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
applicationId,
composeId,
repoPath,
filePath: selectedFile || "",
},
{
enabled: !!selectedFile,
onSuccess: (data) => {
setFileContent(data.content);
setOriginalContent(data.content);
if (data.patchError) {
toast.error(data.patchErrorMessage || "Failed to apply patch");
}
},
},
);
const handleFileSelect = (filePath: string) => {
setSelectedFile(filePath);
};
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const handleSave = () => {
if (!selectedFile) return;
setIsSaving(true);
saveAsPatch.mutate({
applicationId,
composeId,
repoPath,
filePath: selectedFile,
content: fileContent,
});
};
const hasChanges = fileContent !== originalContent;
const renderTree = useCallback(
(entries: DirectoryEntry[], depth = 0) => {
return entries
.sort((a, b) => {
// Directories first, then alphabetically
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((entry) => {
const isExpanded = expandedFolders.has(entry.path);
const isSelected = selectedFile === entry.path;
if (entry.type === "directory") {
return (
<div key={entry.path}>
<button
onClick={() => toggleFolder(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
</div>
);
}
return (
<button
key={entry.path}
onClick={() => handleFileSelect(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
isSelected ? "bg-muted" : ""
}`}
style={{ paddingLeft: `${depth * 12 + 28}px` }}
>
<File className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
</button>
);
});
},
[expandedFolders, selectedFile],
);
return (
<Card className="bg-background overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<CardTitle>Edit File</CardTitle>
<CardDescription>
{selectedFile
? `Editing: ${selectedFile}`
: "Select a file from the tree to edit"}
</CardDescription>
</div>
</div>
{selectedFile && (
<Button onClick={handleSave} disabled={isSaving || !hasChanges}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
{/* File Tree */}
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2">
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : directories ? (
renderTree(directories)
) : (
<div className="text-sm text-muted-foreground p-4">
No files found
</div>
)}
</div>
</ScrollArea>
</div>
{/* Editor */}
<div className="h-full overflow-hidden relative">
{isFileLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : selectedFile ? (
<CodeEditor
value={fileContent}
onChange={(value) => setFileContent(value || "")}
className="h-full w-full"
wrapperClassName="h-full"
lineWrapping
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to edit
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,205 @@
import { AlertCircle, ChevronRight, File, Folder, Loader2, Power, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import type { RouterOutputs } from "@/utils/api";
import { PatchEditor } from "./patch-editor";
interface Props {
applicationId?: string;
composeId?: string;
}
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
export const ShowPatches = ({ applicationId, composeId }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
// Fetch patches
// Fetch patches
const { data: appPatches, isLoading: isAppPatchesLoading } =
api.patch.byApplicationId.useQuery(
{ applicationId: applicationId! },
{ enabled: !!applicationId },
);
const { data: composePatches, isLoading: isComposePatchesLoading } =
api.patch.byComposeId.useQuery(
{ composeId: composeId! },
{ enabled: !!composeId },
);
const patches = applicationId ? appPatches : composePatches;
const isPatchesLoading = applicationId
? isAppPatchesLoading
: isComposePatchesLoading;
// Mutations
const deletePatch = api.patch.delete.useMutation({
onSuccess: () => {
toast.success("Patch deleted");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to delete patch");
},
});
const togglePatch = api.patch.toggleEnabled.useMutation({
onSuccess: () => {
toast.success("Patch updated");
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
},
onError: () => {
toast.error("Failed to update patch");
},
});
const ensureRepo = api.patch.ensureRepo.useMutation();
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
const toastId = toast.loading("Syncing repository...");
ensureRepo.mutate(
{ applicationId, composeId },
{
onSuccess: (path) => {
setRepoPath(path);
setIsLoadingRepo(false);
toast.dismiss(toastId);
},
onError: () => {
setIsLoadingRepo(false);
toast.dismiss(toastId);
toast.error("Failed to load repository");
},
},
);
};
const handleDeletePatch = (patchId: string) => {
deletePatch.mutate({ patchId });
};
const handleTogglePatch = (patchId: string, enabled: boolean) => {
togglePatch.mutate({ patchId, enabled });
};
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
if (applicationId) {
utils.patch.byApplicationId.invalidate({ applicationId });
} else if (composeId) {
utils.patch.byComposeId.invalidate({ composeId });
}
};
if (repoPath) {
return (
<PatchEditor
applicationId={applicationId}
composeId={composeId}
repoPath={repoPath}
onClose={handleCloseEditor}
/>
);
}
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are applied after
cloning the repository and before building.
</CardDescription>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Patch
</Button>
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : !patches || patches.length === 0 ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>No patches</AlertTitle>
<AlertDescription>
No patches have been created for this application yet. Click "Create Patch"
to add modifications to your code during build.
</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patches.map((patch: Patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) =>
handleTogglePatch(patch.patchId, checked)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeletePatch(patch.patchId)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};

View File

@@ -42,6 +42,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
isLoading: cleanStoppedContainersIsLoading,
} = api.settings.cleanStoppedContainers.useMutation();
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
api.patch.cleanPatchRepos.useMutation();
return (
<DropdownMenu>
<DropdownMenuTrigger
@@ -51,7 +54,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
}
>
<Button
@@ -60,7 +64,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
cleanDockerBuilderIsLoading ||
cleanUnusedImagesIsLoading ||
cleanUnusedVolumesIsLoading ||
cleanStoppedContainersIsLoading
cleanStoppedContainersIsLoading ||
cleanPatchReposIsLoading
}
variant="outline"
>
@@ -129,6 +134,23 @@ export const ShowStorageActions = ({ serverId }: Props) => {
</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {
await cleanPatchRepos({
serverId: serverId,
})
.then(async () => {
toast.success("Cleaned Patch Caches");
})
.catch(() => {
toast.error("Error cleaning Patch Caches");
});
}}
>
<span>Clean Patch Caches</span>
</DropdownMenuItem>
<DropdownMenuItem
className="w-full cursor-pointer"
onClick={async () => {

View File

@@ -30,6 +30,7 @@ import { ShowPreviewDeployments } from "@/components/dashboard/application/previ
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
@@ -248,6 +249,9 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "docker" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -359,6 +363,11 @@ const Service = (
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommand applicationId={applicationId} />

View File

@@ -19,6 +19,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
@@ -237,6 +238,9 @@ const Service = (
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{data?.sourceType !== "raw" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -361,6 +365,12 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />

View File

@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
environment: environmentRouter,
patch: patchRouter,
});
// export type definition of API

View File

@@ -0,0 +1,502 @@
import {
checkServiceAccess,
cleanPatchRepos,
createPatch,
deletePatch,
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchById,
findPatchesByApplicationId,
findPatchesByComposeId,
findPatchByFilePath,
generatePatch,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
apiCreatePatch,
apiDeletePatch,
apiFindPatch,
apiFindPatchesByApplicationId,
apiFindPatchesByComposeId,
apiTogglePatchEnabled,
apiUpdatePatch,
} from "@/server/db/schema";
// Helper to get git config from application
const getApplicationGitConfig = (app: Awaited<ReturnType<typeof findApplicationById>>) => {
switch (app.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${app.owner}/${app.repository}.git`,
gitBranch: app.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${app.gitlabOwner}/${app.gitlabRepository}.git`,
gitBranch: app.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: app.gitea?.gitUrl
? `${app.gitea.gitUrl}/${app.giteaOwner}/${app.giteaRepository}.git`
: "",
gitBranch: app.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${app.bitbucketOwner}/${app.bitbucketRepository}.git`,
gitBranch: app.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: app.customGitUrl || "",
gitBranch: app.customGitBranch || "main",
sshKeyId: app.customGitSSHKeyId,
};
default:
return null;
}
};
// Helper to get git config from compose
const getComposeGitConfig = (compose: Awaited<ReturnType<typeof findComposeById>>) => {
switch (compose.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${compose.owner}/${compose.repository}.git`,
gitBranch: compose.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${compose.gitlabOwner}/${compose.gitlabRepository}.git`,
gitBranch: compose.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: compose.gitea?.gitUrl
? `${compose.gitea.gitUrl}/${compose.giteaOwner}/${compose.giteaRepository}.git`
: "",
gitBranch: compose.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${compose.bitbucketOwner}/${compose.bitbucketRepository}.git`,
gitBranch: compose.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: compose.customGitUrl || "",
gitBranch: compose.customGitBranch || "main",
sshKeyId: compose.customGitSSHKeyId,
};
default:
return null;
}
};
export const patchRouter = createTRPCRouter({
// CRUD Operations
create: protectedProcedure
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
// Verify access
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await createPatch(input);
}),
one: protectedProcedure
.input(apiFindPatch)
.query(async ({ input }) => {
return await findPatchById(input.patchId);
}),
byApplicationId: protectedProcedure
.input(apiFindPatchesByApplicationId)
.query(async ({ input, ctx }) => {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findPatchesByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindPatchesByComposeId)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await findPatchesByComposeId(input.composeId);
}),
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input }) => {
const { patchId, ...data } = input;
return await updatePatch(patchId, data);
}),
delete: protectedProcedure
.input(apiDeletePatch)
.mutation(async ({ input }) => {
return await deletePatch(input.patchId);
}),
toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled)
.mutation(async ({ input }) => {
return await updatePatch(input.patchId, { enabled: input.enabled });
}),
// Repository Operations
ensureRepo: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const gitConfig = getApplicationGitConfig(app);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Application does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: app.appName,
type: "application",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: app.serverId,
});
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const gitConfig = getComposeGitConfig(compose);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Compose does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: compose.appName,
type: "compose",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: compose.serverId,
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await readPatchRepoDirectory(input.repoPath, app.serverId);
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await readPatchRepoDirectory(input.repoPath, compose.serverId);
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoFile: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
let serverId: string | null = null;
let patchContent: string | undefined;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
undefined,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
undefined,
input.composeId,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
return await readPatchRepoFile(
input.repoPath,
input.filePath,
patchContent,
serverId,
);
}),
saveFileAsPatch: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
content: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
// Generate patch diff
const patchContent = await generatePatch({
codePath: input.repoPath,
filePath: input.filePath,
newContent: input.content,
serverId,
});
if (!patchContent.trim()) {
// No changes - remove existing patch if any
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
await deletePatch(existingPatch.patchId);
}
return { deleted: true, patchId: null };
}
// Check if patch exists
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
// Update existing patch
await updatePatch(existingPatch.patchId, { content: patchContent });
return { deleted: false, patchId: existingPatch.patchId };
}
// Create new patch
const newPatch = await createPatch({
filePath: input.filePath,
content: patchContent,
enabled: true,
applicationId: input.applicationId,
composeId: input.composeId,
});
return { deleted: false, patchId: newPatch.patchId };
}),
// Cleanup
cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input }) => {
await cleanPatchRepos(input.serverId);
return true;
}),
});

42831
openapi.json

File diff suppressed because it is too large Load Diff

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",
});
}
};