This commit is contained in:
user
2026-02-10 23:43:34 +03:00
parent 744ebab15a
commit ce9ba60902
21 changed files with 24797 additions and 20005 deletions
@@ -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 '[email protected]'");
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,
);
@@ -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 '[email protected]'", { 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 '[email protected]'", { 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} />
+2
View File
@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,
environment: environmentRouter,
patch: patchRouter,
});
// export type definition of API
+502
View File
@@ -0,0 +1,502 @@
import {
checkServiceAccess,
cleanPatchRepos,
createPatch,
deletePatch,
ensurePatchRepo,
findApplicationById,
findComposeById,
findPatchById,
findPatchesByApplicationId,
findPatchesByComposeId,
findPatchByFilePath,
generatePatch,
readPatchRepoDirectory,
readPatchRepoFile,
updatePatch,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
apiCreatePatch,
apiDeletePatch,
apiFindPatch,
apiFindPatchesByApplicationId,
apiFindPatchesByComposeId,
apiTogglePatchEnabled,
apiUpdatePatch,
} from "@/server/db/schema";
// Helper to get git config from application
const getApplicationGitConfig = (app: Awaited<ReturnType<typeof findApplicationById>>) => {
switch (app.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${app.owner}/${app.repository}.git`,
gitBranch: app.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${app.gitlabOwner}/${app.gitlabRepository}.git`,
gitBranch: app.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: app.gitea?.gitUrl
? `${app.gitea.gitUrl}/${app.giteaOwner}/${app.giteaRepository}.git`
: "",
gitBranch: app.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${app.bitbucketOwner}/${app.bitbucketRepository}.git`,
gitBranch: app.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: app.customGitUrl || "",
gitBranch: app.customGitBranch || "main",
sshKeyId: app.customGitSSHKeyId,
};
default:
return null;
}
};
// Helper to get git config from compose
const getComposeGitConfig = (compose: Awaited<ReturnType<typeof findComposeById>>) => {
switch (compose.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${compose.owner}/${compose.repository}.git`,
gitBranch: compose.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${compose.gitlabOwner}/${compose.gitlabRepository}.git`,
gitBranch: compose.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: compose.gitea?.gitUrl
? `${compose.gitea.gitUrl}/${compose.giteaOwner}/${compose.giteaRepository}.git`
: "",
gitBranch: compose.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${compose.bitbucketOwner}/${compose.bitbucketRepository}.git`,
gitBranch: compose.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: compose.customGitUrl || "",
gitBranch: compose.customGitBranch || "main",
sshKeyId: compose.customGitSSHKeyId,
};
default:
return null;
}
};
export const patchRouter = createTRPCRouter({
// CRUD Operations
create: protectedProcedure
.input(apiCreatePatch)
.mutation(async ({ input, ctx }) => {
// Verify access
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await createPatch(input);
}),
one: protectedProcedure
.input(apiFindPatch)
.query(async ({ input }) => {
return await findPatchById(input.patchId);
}),
byApplicationId: protectedProcedure
.input(apiFindPatchesByApplicationId)
.query(async ({ input, ctx }) => {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findPatchesByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindPatchesByComposeId)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await findPatchesByComposeId(input.composeId);
}),
update: protectedProcedure
.input(apiUpdatePatch)
.mutation(async ({ input }) => {
const { patchId, ...data } = input;
return await updatePatch(patchId, data);
}),
delete: protectedProcedure
.input(apiDeletePatch)
.mutation(async ({ input }) => {
return await deletePatch(input.patchId);
}),
toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled)
.mutation(async ({ input }) => {
return await updatePatch(input.patchId, { enabled: input.enabled });
}),
// Repository Operations
ensureRepo: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const gitConfig = getApplicationGitConfig(app);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Application does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: app.appName,
type: "application",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: app.serverId,
});
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const gitConfig = getComposeGitConfig(compose);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Compose does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: compose.appName,
type: "compose",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: compose.serverId,
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoDirectories: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await readPatchRepoDirectory(input.repoPath, app.serverId);
}
if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await readPatchRepoDirectory(input.repoPath, compose.serverId);
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}),
readRepoFile: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
}),
)
.query(async ({ input, ctx }) => {
let serverId: string | null = null;
let patchContent: string | undefined;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
undefined,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
// Check if patch exists for this file
const existingPatch = await findPatchByFilePath(
input.filePath,
undefined,
input.composeId,
);
if (existingPatch?.enabled) {
patchContent = existingPatch.content;
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
return await readPatchRepoFile(
input.repoPath,
input.filePath,
patchContent,
serverId,
);
}),
saveFileAsPatch: protectedProcedure
.input(
z.object({
applicationId: z.string().optional(),
composeId: z.string().optional(),
repoPath: z.string(),
filePath: z.string(),
content: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
let serverId: string | null = null;
if (input.applicationId) {
const app = await findApplicationById(input.applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId;
} else if (input.composeId) {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either applicationId or composeId must be provided",
});
}
// Generate patch diff
const patchContent = await generatePatch({
codePath: input.repoPath,
filePath: input.filePath,
newContent: input.content,
serverId,
});
if (!patchContent.trim()) {
// No changes - remove existing patch if any
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
await deletePatch(existingPatch.patchId);
}
return { deleted: true, patchId: null };
}
// Check if patch exists
const existingPatch = await findPatchByFilePath(
input.filePath,
input.applicationId,
input.composeId,
);
if (existingPatch) {
// Update existing patch
await updatePatch(existingPatch.patchId, { content: patchContent });
return { deleted: false, patchId: existingPatch.patchId };
}
// Create new patch
const newPatch = await createPatch({
filePath: input.filePath,
content: patchContent,
enabled: true,
applicationId: input.applicationId,
composeId: input.composeId,
});
return { deleted: false, patchId: newPatch.patchId };
}),
// Cleanup
cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input }) => {
await cleanPatchRepos(input.serverId);
return true;
}),
});