mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-26 09:35:29 +02:00
Compare commits
3 Commits
v0.27.0
...
patches-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb0bfea1 | ||
|
|
ce9ba60902 | ||
|
|
744ebab15a |
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ApplicationNested } from "@dokploy/server";
|
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
|
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
|
// Mock ONLY database and notifications
|
||||||
vi.mock("@dokploy/server/db", () => {
|
vi.mock("@dokploy/server/db", () => {
|
||||||
const createChainableMock = (): any => {
|
const createChainableMock = (): any => {
|
||||||
@@ -67,6 +79,16 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
|
|||||||
createRollback: vi.fn(),
|
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):
|
// NOT mocked (executed for real):
|
||||||
// - execAsync
|
// - execAsync
|
||||||
// - cloneGitRepository
|
// - cloneGitRepository
|
||||||
@@ -78,6 +100,11 @@ import * as adminService from "@dokploy/server/services/admin";
|
|||||||
import * as applicationService from "@dokploy/server/services/application";
|
import * as applicationService from "@dokploy/server/services/application";
|
||||||
import { deployApplication } from "@dokploy/server/services/application";
|
import { deployApplication } from "@dokploy/server/services/application";
|
||||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
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 = (
|
const createMockApplication = (
|
||||||
overrides: Partial<ApplicationNested> = {},
|
overrides: Partial<ApplicationNested> = {},
|
||||||
@@ -474,6 +501,105 @@ describe(
|
|||||||
},
|
},
|
||||||
REAL_TEST_TIMEOUT,
|
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,
|
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,
|
isLoading: cleanStoppedContainersIsLoading,
|
||||||
} = api.settings.cleanStoppedContainers.useMutation();
|
} = api.settings.cleanStoppedContainers.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
|
||||||
|
api.patch.cleanPatchRepos.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -51,7 +54,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -60,7 +64,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
cleanDockerBuilderIsLoading ||
|
cleanDockerBuilderIsLoading ||
|
||||||
cleanUnusedImagesIsLoading ||
|
cleanUnusedImagesIsLoading ||
|
||||||
cleanUnusedVolumesIsLoading ||
|
cleanUnusedVolumesIsLoading ||
|
||||||
cleanStoppedContainersIsLoading
|
cleanStoppedContainersIsLoading ||
|
||||||
|
cleanPatchReposIsLoading
|
||||||
}
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -129,6 +134,23 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "patch" (
|
||||||
|
"patchId" text PRIMARY KEY NOT NULL,
|
||||||
|
"filePath" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"updatedAt" text,
|
||||||
|
"applicationId" text,
|
||||||
|
"composeId" text,
|
||||||
|
CONSTRAINT "patch_filepath_application_unique" UNIQUE("filePath","applicationId"),
|
||||||
|
CONSTRAINT "patch_filepath_compose_unique" UNIQUE("filePath","composeId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "patch" ADD CONSTRAINT "patch_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "patch" ADD CONSTRAINT "patch_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
|
||||||
7390
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
7390
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1002,6 +1002,13 @@
|
|||||||
"when": 1770615019498,
|
"when": 1770615019498,
|
||||||
"tag": "0142_outstanding_tusk",
|
"tag": "0142_outstanding_tusk",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 143,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1770756316554,
|
||||||
|
"tag": "0143_cute_forge",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ import { ShowPreviewDeployments } from "@/components/dashboard/application/previ
|
|||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
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 { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
@@ -248,6 +249,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "docker" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -359,6 +363,11 @@ const Service = (
|
|||||||
<ShowDomains id={applicationId} type="application" />
|
<ShowDomains id={applicationId} type="application" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommand applicationId={applicationId} />
|
<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 { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
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 { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
@@ -237,6 +238,9 @@ const Service = (
|
|||||||
Volume Backups
|
Volume Backups
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
|
{data?.sourceType !== "raw" && (
|
||||||
|
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||||
|
)}
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
@@ -361,6 +365,12 @@ const Service = (
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="advanced">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<AddCommandCompose composeId={composeId} />
|
<AddCommandCompose composeId={composeId} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
|
|||||||
import { mysqlRouter } from "./routers/mysql";
|
import { mysqlRouter } from "./routers/mysql";
|
||||||
import { notificationRouter } from "./routers/notification";
|
import { notificationRouter } from "./routers/notification";
|
||||||
import { organizationRouter } from "./routers/organization";
|
import { organizationRouter } from "./routers/organization";
|
||||||
|
import { patchRouter } from "./routers/patch";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { portRouter } from "./routers/port";
|
import { portRouter } from "./routers/port";
|
||||||
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
rollback: rollbackRouter,
|
rollback: rollbackRouter,
|
||||||
volumeBackups: volumeBackupsRouter,
|
volumeBackups: volumeBackupsRouter,
|
||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
|
patch: patchRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
deployApplication,
|
deployApplication,
|
||||||
deployCompose,
|
deployCompose,
|
||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
|
IS_CLOUD,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
rebuildPreviewApplication,
|
rebuildPreviewApplication,
|
||||||
@@ -13,70 +14,83 @@ import { type Job, Worker } from "bullmq";
|
|||||||
import type { DeploymentJob } from "./queue-types";
|
import type { DeploymentJob } from "./queue-types";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
export const deploymentWorker = new Worker(
|
const createDeploymentWorker = () =>
|
||||||
"deployments",
|
new Worker(
|
||||||
async (job: Job<DeploymentJob>) => {
|
"deployments",
|
||||||
try {
|
async (job: Job<DeploymentJob>) => {
|
||||||
if (job.data.applicationType === "application") {
|
try {
|
||||||
await updateApplicationStatus(job.data.applicationId, "running");
|
if (job.data.applicationType === "application") {
|
||||||
|
await updateApplicationStatus(job.data.applicationId, "running");
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
if (job.data.type === "redeploy") {
|
||||||
await rebuildApplication({
|
await rebuildApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (job.data.applicationType === "compose") {
|
||||||
|
await updateCompose(job.data.composeId, {
|
||||||
|
composeStatus: "running",
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "deploy") {
|
if (job.data.type === "deploy") {
|
||||||
await deployApplication({
|
await deployCompose({
|
||||||
applicationId: job.data.applicationId,
|
composeId: job.data.composeId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "redeploy") {
|
||||||
|
await rebuildCompose({
|
||||||
|
composeId: job.data.composeId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (job.data.applicationType === "application-preview") {
|
||||||
|
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||||
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else if (job.data.applicationType === "compose") {
|
|
||||||
await updateCompose(job.data.composeId, {
|
|
||||||
composeStatus: "running",
|
|
||||||
});
|
|
||||||
if (job.data.type === "deploy") {
|
|
||||||
await deployCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
} else if (job.data.type === "redeploy") {
|
|
||||||
await rebuildCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (job.data.applicationType === "application-preview") {
|
|
||||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
|
||||||
previewStatus: "running",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (job.data.type === "redeploy") {
|
if (job.data.type === "redeploy") {
|
||||||
await rebuildPreviewApplication({
|
await rebuildPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
} else if (job.data.type === "deploy") {
|
} else if (job.data.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error", error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log("Error", error);
|
{
|
||||||
}
|
autorun: false,
|
||||||
},
|
connection: redisConfig,
|
||||||
{
|
},
|
||||||
autorun: false,
|
);
|
||||||
connection: redisConfig,
|
|
||||||
},
|
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||||
);
|
const noopWorker = {
|
||||||
|
run: () => Promise.resolve(),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
cancelJob: () => Promise.resolve(),
|
||||||
|
cancelAllJobs: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deploymentWorker = !IS_CLOUD
|
||||||
|
? createDeploymentWorker()
|
||||||
|
: (noopWorker as unknown as Worker<DeploymentJob>);
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import type { Job } from "bullmq";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { deploymentWorker } from "./deployments-queue";
|
import { deploymentWorker } from "./deployments-queue";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
const myQueue = new Queue("deployments", {
|
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||||
connection: redisConfig,
|
const createNoopQueue = () => ({
|
||||||
|
getJobs: () => Promise.resolve([] as Job[]),
|
||||||
|
add: () =>
|
||||||
|
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
on: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const myQueue = !IS_CLOUD
|
||||||
|
? new Queue("deployments", { connection: redisConfig })
|
||||||
|
: (createNoopQueue() as unknown as Queue);
|
||||||
|
|
||||||
export const getJobsByApplicationId = async (applicationId: string) => {
|
export const getJobsByApplicationId = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs();
|
const jobs = await myQueue.getJobs();
|
||||||
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
||||||
@@ -20,19 +31,21 @@ export const getJobsByComposeId = async (composeId: string) => {
|
|||||||
return jobs.filter((job) => job?.data?.composeId === composeId);
|
return jobs.filter((job) => job?.data?.composeId === composeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
if (!IS_CLOUD) {
|
||||||
myQueue.close();
|
process.on("SIGTERM", () => {
|
||||||
process.exit(0);
|
myQueue.close();
|
||||||
});
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
myQueue.on("error", (error) => {
|
myQueue.on("error", (error) => {
|
||||||
if ((error as any).code === "ECONNREFUSED") {
|
if ((error as any).code === "ECONNREFUSED") {
|
||||||
console.error(
|
console.error(
|
||||||
"Make sure you have installed Redis and it is running.",
|
"Make sure you have installed Redis and it is running.",
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const cleanQueuesByApplication = async (applicationId: string) => {
|
export const cleanQueuesByApplication = async (applicationId: string) => {
|
||||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||||
|
|||||||
42829
openapi.json
42829
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`,
|
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
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 { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { ports } from "./port";
|
import { ports } from "./port";
|
||||||
import { previewDeployments } from "./preview-deployments";
|
import { previewDeployments } from "./preview-deployments";
|
||||||
import { redirects } from "./redirects";
|
import { redirects } from "./redirects";
|
||||||
@@ -286,6 +287,7 @@ export const applicationsRelations = relations(
|
|||||||
references: [registry.registryId],
|
references: [registry.registryId],
|
||||||
relationName: "applicationRollbackRegistry",
|
relationName: "applicationRollbackRegistry",
|
||||||
}),
|
}),
|
||||||
|
patches: many(patch),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { gitea } from "./gitea";
|
|||||||
import { github } from "./github";
|
import { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
|
import { patch } from "./patch";
|
||||||
import { schedules } from "./schedule";
|
import { schedules } from "./schedule";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { applicationStatus, triggerType } from "./shared";
|
import { applicationStatus, triggerType } from "./shared";
|
||||||
@@ -143,6 +144,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
|||||||
}),
|
}),
|
||||||
backups: many(backups),
|
backups: many(backups),
|
||||||
schedules: many(schedules),
|
schedules: many(schedules),
|
||||||
|
patches: many(patch),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(compose, {
|
const createSchema = createInsertSchema(compose, {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export * from "./mongo";
|
|||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
export * from "./mysql";
|
export * from "./mysql";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./patch";
|
||||||
export * from "./port";
|
export * from "./port";
|
||||||
export * from "./postgres";
|
export * from "./postgres";
|
||||||
export * from "./preview-deployments";
|
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/mount";
|
||||||
export * from "./services/mysql";
|
export * from "./services/mysql";
|
||||||
export * from "./services/notification";
|
export * from "./services/notification";
|
||||||
|
export * from "./services/patch";
|
||||||
|
export * from "./services/patch-repo";
|
||||||
export * from "./services/port";
|
export * from "./services/port";
|
||||||
export * from "./services/postgres";
|
export * from "./services/postgres";
|
||||||
export * from "./services/preview-deployment";
|
export * from "./services/preview-deployment";
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
issueCommentExists,
|
issueCommentExists,
|
||||||
updateIssueComment,
|
updateIssueComment,
|
||||||
} from "./github";
|
} from "./github";
|
||||||
|
import {
|
||||||
|
findPatchesByApplicationId,
|
||||||
|
generateApplyPatchesCommand,
|
||||||
|
} from "./patch";
|
||||||
import {
|
import {
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -202,6 +206,20 @@ export const deployApplication = async ({
|
|||||||
command += await buildRemoteDocker(application);
|
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);
|
command += await getBuildCommand(application);
|
||||||
|
|
||||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ import {
|
|||||||
updateDeployment,
|
updateDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "./deployment";
|
} from "./deployment";
|
||||||
|
import {
|
||||||
|
findPatchesByComposeId,
|
||||||
|
generateApplyPatchesCommand,
|
||||||
|
} from "./patch";
|
||||||
import { validUniqueServerAppName } from "./project";
|
import { validUniqueServerAppName } from "./project";
|
||||||
|
|
||||||
export type Compose = typeof compose.$inferSelect;
|
export type Compose = typeof compose.$inferSelect;
|
||||||
@@ -248,6 +252,26 @@ export const deployCompose = async ({
|
|||||||
await execAsync(commandWithLog);
|
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 = "set -e;";
|
||||||
command += await getBuildComposeCommand(entity);
|
command += await getBuildComposeCommand(entity);
|
||||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
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