mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge pull request #188 from Dokploy/feat/enable-autodeploy-github
feat(github-webhooks): #186 implement github autodeployment with zero…
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -26,8 +25,6 @@ export const DeployApplication = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: markRunning } =
|
||||
api.application.markRunning.useMutation();
|
||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||
|
||||
return (
|
||||
@@ -48,24 +45,16 @@ export const DeployApplication = ({ applicationId }: Props) => {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
toast.success("Deploying Application....");
|
||||
|
||||
await refetch();
|
||||
await deploy({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Deploying Application....");
|
||||
}).catch(() => {
|
||||
toast.error("Error to deploy Application");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
await deploy({
|
||||
applicationId,
|
||||
}).catch(() => {
|
||||
toast.error("Error to deploy Application");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e.message || "Error to deploy Application");
|
||||
});
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
|
||||
@@ -25,8 +25,7 @@ export const RedbuildApplication = ({ applicationId }: Props) => {
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: markRunning } =
|
||||
api.application.markRunning.useMutation();
|
||||
|
||||
const { mutateAsync } = api.application.redeploy.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
@@ -54,22 +53,14 @@ export const RedbuildApplication = ({ applicationId }: Props) => {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
toast.success("Redeploying Application....");
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await mutateAsync({
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
toast.success("Application rebuild succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the application");
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the application");
|
||||
|
||||
@@ -9,14 +9,11 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
// import { CancelQueues } from "./cancel-queues";
|
||||
// import { ShowDeployment } from "./show-deployment-compose";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
||||
import { RefreshTokenCompose } from "./refresh-token-compose";
|
||||
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
||||
// import { RefreshToken } from "./refresh-token";//
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
@@ -90,6 +87,11 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
|
||||
@@ -25,7 +25,6 @@ export const DeployCompose = ({ composeId }: Props) => {
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
|
||||
const { mutateAsync: markRunning } = api.compose.update.useMutation();
|
||||
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
|
||||
|
||||
return (
|
||||
@@ -44,25 +43,16 @@ export const DeployCompose = ({ composeId }: Props) => {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
toast.success("Deploying Compose....");
|
||||
|
||||
await refetch();
|
||||
await deploy({
|
||||
composeId,
|
||||
composeStatus: "running",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Deploying Compose....");
|
||||
}).catch(() => {
|
||||
toast.error("Error to deploy Compose");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
await deploy({
|
||||
composeId,
|
||||
}).catch(() => {
|
||||
toast.error("Error to deploy Compose");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e.message || "Error to deploy Compose");
|
||||
});
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
|
||||
@@ -25,7 +25,6 @@ export const RedbuildCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { mutateAsync: markRunning } = api.compose.update.useMutation();
|
||||
const { mutateAsync } = api.compose.redeploy.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
@@ -53,23 +52,14 @@ export const RedbuildCompose = ({ composeId }: Props) => {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
toast.success("Redeploying Compose....");
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
composeStatus: "running",
|
||||
})
|
||||
.then(async () => {
|
||||
await mutateAsync({
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose rebuild succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the compose");
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to rebuild the compose");
|
||||
|
||||
@@ -25,7 +25,6 @@ export const StopCompose = ({ composeId }: Props) => {
|
||||
},
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { mutateAsync: markRunning } = api.compose.update.useMutation();
|
||||
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
@@ -47,23 +46,14 @@ export const StopCompose = ({ composeId }: Props) => {
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await markRunning({
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
composeStatus: "running",
|
||||
})
|
||||
.then(async () => {
|
||||
await mutateAsync({
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose rebuild succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to stop the compose");
|
||||
});
|
||||
});
|
||||
toast.success("Compose stopped succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to stop the compose");
|
||||
|
||||
@@ -54,7 +54,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0">
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-4 dark:bg-black p-6 border-b">
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-4 bg-background p-6 border-b">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -48,6 +48,7 @@ export const GithubSetup = () => {
|
||||
const [organizationName, setOrganization] = useState<string>("");
|
||||
const { data } = api.admin.one.useQuery();
|
||||
useEffect(() => {
|
||||
const url = document.location.origin;
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
redirect_url: `${origin}/api/redirect?authId=${data?.authId}`,
|
||||
@@ -55,7 +56,7 @@ export const GithubSetup = () => {
|
||||
url: origin,
|
||||
hook_attributes: {
|
||||
// JUST FOR TESTING
|
||||
url: "https://webhook.site/b6a167c0-ceb5-4f0c-a257-97c0fd163977",
|
||||
url: `${url}/api/deploy/github`,
|
||||
// url: `${origin}/api/webhook`, // Aquí especificas la URL del endpoint de tu webhook
|
||||
},
|
||||
callback_urls: [`${origin}/api/redirect`], // Los URLs de callback para procesos de autenticación
|
||||
@@ -92,8 +93,18 @@ export const GithubSetup = () => {
|
||||
</span>
|
||||
<BadgeCheck className="size-4 text-green-700" />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<RemoveGithubApp />
|
||||
<Link
|
||||
href={`https://github.com/settings/apps/${data?.githubAppName}`}
|
||||
target="_blank"
|
||||
className={buttonVariants({
|
||||
className: "w-fit",
|
||||
variant: "secondary",
|
||||
})}
|
||||
>
|
||||
<span className="text-sm">Manage Github App</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
export const RemoveGithubApp = () => {
|
||||
const { refetch } = api.auth.get.useQuery();
|
||||
@@ -22,7 +29,20 @@ export const RemoveGithubApp = () => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Remove Current Github App</Button>
|
||||
<Button variant="destructive">
|
||||
Remove Current Github App
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="size-4 fill-muted-destructive text-muted-destructive" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
We recommend deleting the GitHub app first, and then removing
|
||||
the current one from here.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
1
drizzle/0018_careful_killmonger.sql
Normal file
1
drizzle/0018_careful_killmonger.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "admin" ADD COLUMN "githubWebhookSecret" text;
|
||||
2632
drizzle/meta/0018_snapshot.json
Normal file
2632
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
||||
"when": 1719547174326,
|
||||
"tag": "0017_minor_post",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "6",
|
||||
"when": 1719928377858,
|
||||
"tag": "0018_careful_killmonger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -36,10 +36,12 @@
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/legacy-modes": "6.4.0",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@lucia-auth/adapter-drizzle": "1.0.7",
|
||||
"@octokit/auth-app": "^6.0.4",
|
||||
"@octokit/webhooks": "^13.2.7",
|
||||
"@radix-ui/react-accordion": "1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -113,7 +115,6 @@
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tar-fs": "3.0.5",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { updateApplicationStatus } from "@/server/api/services/application";
|
||||
import { db } from "@/server/db";
|
||||
import { applications } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/deployments-queue";
|
||||
@@ -68,11 +67,6 @@ export default async function handler(
|
||||
}
|
||||
|
||||
try {
|
||||
await updateApplicationStatus(
|
||||
application.applicationId as string,
|
||||
"running",
|
||||
);
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: application.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
extractCommitMessage,
|
||||
extractHash,
|
||||
} from "../[refreshToken]";
|
||||
import { updateCompose } from "@/server/api/services/compose";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -56,10 +55,6 @@ export default async function handler(
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCompose(composeResult.composeId, {
|
||||
composeStatus: "running",
|
||||
});
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: composeResult.composeId as string,
|
||||
titleLog: deploymentTitle,
|
||||
|
||||
114
pages/api/deploy/github.ts
Normal file
114
pages/api/deploy/github.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/server/db";
|
||||
import { Webhooks } from "@octokit/webhooks";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { applications, compose } from "@/server/db/schema";
|
||||
import { extractCommitMessage, extractHash } from "./[refreshToken]";
|
||||
import type { DeploymentJob } from "@/server/queues/deployments-queue";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const admin = await db.query.admins.findFirst({});
|
||||
|
||||
if (!admin) {
|
||||
res.status(200).json({ message: "Could not find admin" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!admin.githubWebhookSecret) {
|
||||
res.status(200).json({ message: "Github Webhook Secret not set" });
|
||||
return;
|
||||
}
|
||||
|
||||
const webhooks = new Webhooks({
|
||||
secret: admin.githubWebhookSecret,
|
||||
});
|
||||
|
||||
const signature = req.headers["x-hub-signature-256"];
|
||||
const github = req.body;
|
||||
|
||||
const verified = await webhooks.verify(JSON.stringify(github), signature as string);
|
||||
|
||||
if (!verified) {
|
||||
res.status(401).json({ message: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers["x-github-event"] === "ping") {
|
||||
res.status(200).json({ message: "Ping received, webhook is active" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers["x-github-event"] !== "push") {
|
||||
res.status(400).json({ message: "We only accept push events" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const branchName = github?.ref?.replace("refs/heads/", "");
|
||||
const repository = github?.repository?.name;
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
|
||||
const apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
eq(applications.sourceType, "github"),
|
||||
eq(applications.autoDeploy, true),
|
||||
eq(applications.branch, branchName),
|
||||
eq(applications.repository, repository)
|
||||
),
|
||||
});
|
||||
|
||||
for (const app of apps) {
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: app.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
};
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const composeApps = await db.query.compose.findMany({
|
||||
where: and(eq(compose.sourceType, "github"), eq(compose.autoDeploy, true), eq(compose.branch, branchName), eq(compose.repository, repository)),
|
||||
});
|
||||
|
||||
for (const composeApp of composeApps) {
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: composeApp.composeId as string,
|
||||
titleLog: deploymentTitle,
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
};
|
||||
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const totalApps = apps.length + composeApps.length;
|
||||
const emptyApps = totalApps === 0;
|
||||
|
||||
if (emptyApps) {
|
||||
res.status(200).json({ message: "No apps to deploy" });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Error To Deploy Application", error });
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export default async function handler(
|
||||
githubAppName: data.name,
|
||||
githubClientId: data.client_id,
|
||||
githubClientSecret: data.client_secret,
|
||||
githubWebhookSecret: data.webhook_secret,
|
||||
githubPrivateKey: data.pem,
|
||||
})
|
||||
.where(eq(admins.authId, authId as string))
|
||||
|
||||
13925
pnpm-lock.yaml
generated
13925
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ export const admins = pgTable("admin", {
|
||||
githubClientSecret: text("githubClientSecret"),
|
||||
githubInstallationId: text("githubInstallationId"),
|
||||
githubPrivateKey: text("githubPrivateKey"),
|
||||
githubWebhookSecret: text("githubWebhookSecret"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
|
||||
@@ -2,9 +2,14 @@ import { type Job, Worker } from "bullmq";
|
||||
import {
|
||||
deployApplication,
|
||||
rebuildApplication,
|
||||
updateApplicationStatus,
|
||||
} from "../api/services/application";
|
||||
import { myQueue, redisConfig } from "./queueSetup";
|
||||
import { deployCompose, rebuildCompose } from "../api/services/compose";
|
||||
import {
|
||||
deployCompose,
|
||||
rebuildCompose,
|
||||
updateCompose,
|
||||
} from "../api/services/compose";
|
||||
|
||||
type DeployJob =
|
||||
| {
|
||||
@@ -29,6 +34,7 @@ export const deploymentWorker = new Worker(
|
||||
async (job: Job<DeploymentJob>) => {
|
||||
try {
|
||||
if (job.data.applicationType === "application") {
|
||||
await updateApplicationStatus(job.data.applicationId, "running");
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
@@ -43,6 +49,9 @@ export const deploymentWorker = new Worker(
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "compose") {
|
||||
await updateCompose(job.data.composeId, {
|
||||
composeStatus: "running",
|
||||
});
|
||||
if (job.data.type === "deploy") {
|
||||
await deployCompose({
|
||||
composeId: job.data.composeId,
|
||||
|
||||
Reference in New Issue
Block a user