From ce9ba60902d5d9e8ec279c3f2928e97b83be434c Mon Sep 17 00:00:00 2001 From: user Date: Fri, 30 Jan 2026 20:23:32 +0300 Subject: [PATCH] impl --- .../__test__/deploy/application.real.test.ts | 126 + .../patches/patch.integration.test.ts | 106 + .../dashboard/application/patches/index.ts | 2 + .../application/patches/patch-editor.tsx | 235 + .../application/patches/show-patches.tsx | 205 + .../servers/actions/show-storage-actions.tsx | 26 +- .../services/application/[applicationId].tsx | 9 + .../services/compose/[composeId].tsx | 10 + apps/dokploy/server/api/root.ts | 2 + apps/dokploy/server/api/routers/patch.ts | 502 + openapi.json | 42831 ++++++++-------- packages/server/src/constants/index.ts | 1 + packages/server/src/db/schema/application.ts | 2 + packages/server/src/db/schema/compose.ts | 2 + packages/server/src/db/schema/index.ts | 1 + packages/server/src/db/schema/patch.ts | 95 + packages/server/src/index.ts | 2 + packages/server/src/services/application.ts | 18 + packages/server/src/services/compose.ts | 24 + packages/server/src/services/patch-repo.ts | 308 + packages/server/src/services/patch.ts | 295 + 21 files changed, 24797 insertions(+), 20005 deletions(-) create mode 100644 apps/dokploy/__test__/patches/patch.integration.test.ts create mode 100644 apps/dokploy/components/dashboard/application/patches/index.ts create mode 100644 apps/dokploy/components/dashboard/application/patches/patch-editor.tsx create mode 100644 apps/dokploy/components/dashboard/application/patches/show-patches.tsx create mode 100644 apps/dokploy/server/api/routers/patch.ts create mode 100644 packages/server/src/db/schema/patch.ts create mode 100644 packages/server/src/services/patch-repo.ts create mode 100644 packages/server/src/services/patch.ts diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 43ff07836..3831212d7 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -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 = {}, @@ -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, ); diff --git a/apps/dokploy/__test__/patches/patch.integration.test.ts b/apps/dokploy/__test__/patches/patch.integration.test.ts new file mode 100644 index 000000000..2e370022e --- /dev/null +++ b/apps/dokploy/__test__/patches/patch.integration.test.ts @@ -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); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/patches/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts new file mode 100644 index 000000000..1854bd3e5 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -0,0 +1,2 @@ +export * from "./show-patches"; +export * from "./patch-editor"; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx new file mode 100644 index 000000000..bd7e6e83a --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -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(null); + const [fileContent, setFileContent] = useState(""); + const [originalContent, setOriginalContent] = useState(""); + const [expandedFolders, setExpandedFolders] = useState>(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 ( +
+ + {isExpanded && entry.children && ( +
{renderTree(entry.children, depth + 1)}
+ )} +
+ ); + } + + return ( + + ); + }); + }, + [expandedFolders, selectedFile], + ); + + return ( + + +
+ +
+ Edit File + + {selectedFile + ? `Editing: ${selectedFile}` + : "Select a file from the tree to edit"} + +
+
+ {selectedFile && ( + + )} +
+ +
+ {/* File Tree */} +
+ +
+ {isDirLoading ? ( +
+ +
+ ) : directories ? ( + renderTree(directories) + ) : ( +
+ No files found +
+ )} +
+
+
+ {/* Editor */} +
+ {isFileLoading ? ( +
+ +
+ ) : selectedFile ? ( + setFileContent(value || "")} + className="h-full w-full" + wrapperClassName="h-full" + lineWrapping + /> + ) : ( +
+ Select a file to edit +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx new file mode 100644 index 000000000..e42269886 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -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(null); + const [repoPath, setRepoPath] = useState(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 ( + + ); + } + + return ( + + +
+ Patches + + Apply code patches to your repository during build. Patches are applied after + cloning the repository and before building. + +
+ +
+ + {isPatchesLoading ? ( +
+ +
+ ) : !patches || patches.length === 0 ? ( + + + No patches + + No patches have been created for this application yet. Click "Create Patch" + to add modifications to your code during build. + + + ) : ( + + + + File Path + Enabled + Actions + + + + {patches.map((patch: Patch) => ( + + +
+ + {patch.filePath} +
+
+ + + handleTogglePatch(patch.patchId, checked) + } + /> + + + + +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx index c80648142..5bf3b8fc6 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx @@ -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 ( { cleanDockerBuilderIsLoading || cleanUnusedImagesIsLoading || cleanUnusedVolumesIsLoading || - cleanStoppedContainersIsLoading + cleanStoppedContainersIsLoading || + cleanPatchReposIsLoading } >