mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
impl
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal file
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
502
apps/dokploy/server/api/routers/patch.ts
Normal file
502
apps/dokploy/server/api/routers/patch.ts
Normal 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
42831
openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
|
||||
95
packages/server/src/db/schema/patch.ts
Normal file
95
packages/server/src/db/schema/patch.ts
Normal 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(),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
308
packages/server/src/services/patch-repo.ts
Normal file
308
packages/server/src/services/patch-repo.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
295
packages/server/src/services/patch.ts
Normal file
295
packages/server/src/services/patch.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user