diff --git a/.circleci/config.yml b/.circleci/config.yml index 25982d59..ff3a65c9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -99,14 +99,14 @@ workflows: only: - main - canary - - refactor/enhancement-languages + - fix/nixpacks-version - build-arm64: filters: branches: only: - main - canary - - refactor/enhancement-languages + - fix/nixpacks-version - combine-manifests: requires: - build-amd64 @@ -116,4 +116,4 @@ workflows: only: - main - canary - - refactor/enhancement-languages + - fix/nixpacks-version diff --git a/Dockerfile b/Dockerfile index 74b70db0..51be6469 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash + +ARG NIXPACKS_VERSION=1.29.1 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ diff --git a/LICENSE.MD b/LICENSE.MD index 59e9d822..8a508efb 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations ## Additional Terms for Specific Features -The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: +The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service. +- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. +- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. +- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. For further inquiries or permissions, please contact us directly. diff --git a/apps/api/src/schema.ts b/apps/api/src/schema.ts index 5f26e018..609289bf 100644 --- a/apps/api/src/schema.ts +++ b/apps/api/src/schema.ts @@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [ applicationType: z.literal("compose"), serverId: z.string().min(1), }), + z.object({ + applicationId: z.string(), + previewDeploymentId: z.string(), + titleLog: z.string(), + descriptionLog: z.string(), + server: z.boolean().optional(), + type: z.enum(["deploy"]), + applicationType: z.literal("application-preview"), + serverId: z.string().min(1), + }), ]); export type DeployJob = z.infer; diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts index 2654487f..d919f29e 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -1,10 +1,12 @@ import { deployRemoteApplication, deployRemoteCompose, + deployRemotePreviewApplication, rebuildRemoteApplication, rebuildRemoteCompose, updateApplicationStatus, updateCompose, + updatePreviewDeployment, } from "@dokploy/server"; import type { DeployJob } from "./schema"; @@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => { }); } } + } else if (job.applicationType === "application-preview") { + await updatePreviewDeployment(job.previewDeploymentId, { + previewStatus: "running", + }); + if (job.server) { + if (job.type === "deploy") { + await deployRemotePreviewApplication({ + applicationId: job.applicationId, + titleLog: job.titleLog, + descriptionLog: job.descriptionLog, + previewDeploymentId: job.previewDeploymentId, + }); + } + } } } catch (error) { if (job.applicationType === "application") { @@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => { await updateCompose(job.composeId, { composeStatus: "error", }); + } else if (job.applicationType === "application-preview") { + await updatePreviewDeployment(job.previewDeploymentId, { + previewStatus: "error", + }); } } diff --git a/apps/dokploy/LICENSE.MD b/apps/dokploy/LICENSE.MD index 59e9d822..8a508efb 100644 --- a/apps/dokploy/LICENSE.MD +++ b/apps/dokploy/LICENSE.MD @@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations ## Additional Terms for Specific Features -The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: +The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service. +- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. +- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. +- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. For further inquiries or permissions, please contact us directly. diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 8b5c2f9c..8bc9fbcc 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -17,6 +17,7 @@ describe("createDomainLabels", () => { domainId: "", path: "/", createdAt: "", + previewDeploymentId: "", }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index 9a6473af..c4b2ba8d 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -34,6 +34,15 @@ const baseApp: ApplicationNested = { registryUrl: "", branch: null, dockerBuildStage: "", + isPreviewDeploymentsActive: false, + previewBuildArgs: null, + previewCertificateType: "none", + previewEnv: null, + previewHttps: false, + previewPath: "/", + previewPort: 3000, + previewLimit: 0, + previewWildcard: "", project: { env: "", adminId: "", diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index d7ad29ab..d05dda81 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -15,6 +15,15 @@ const baseApp: ApplicationNested = { dockerBuildStage: "", registryUrl: "", buildArgs: null, + isPreviewDeploymentsActive: false, + previewBuildArgs: null, + previewCertificateType: "none", + previewEnv: null, + previewHttps: false, + previewPath: "/", + previewPort: 3000, + previewLimit: 0, + previewWildcard: "", project: { env: "", adminId: "", @@ -96,6 +105,7 @@ const baseDomain: Domain = { composeId: "", domainType: "application", uniqueConfigKey: 1, + previewDeploymentId: "", }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index c2288bb8..a767350f 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => { refetchInterval: 1000, }, ); + const [url, setUrl] = React.useState(""); useEffect(() => { setUrl(document.location.origin); diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 9b38f1ce..c7b8d4bb 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { onSubmit={form.handleSubmit(onSubmit)} className="flex w-full flex-col gap-5 " > - + ; + +interface Props { + previewDeploymentId: string; + domainId?: string; + children: React.ReactNode; +} + +export const AddPreviewDomain = ({ + previewDeploymentId, + domainId = "", + children, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + const { data, refetch } = api.domain.one.useQuery( + { + domainId, + }, + { + enabled: !!domainId, + }, + ); + + const { data: previewDeployment } = api.previewDeployment.one.useQuery( + { + previewDeploymentId, + }, + { + enabled: !!previewDeploymentId, + }, + ); + + const { mutateAsync, isError, error, isLoading } = domainId + ? api.domain.update.useMutation() + : api.domain.create.useMutation(); + + const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + api.domain.generateDomain.useMutation(); + + const form = useForm({ + resolver: zodResolver(domain), + }); + + useEffect(() => { + if (data) { + form.reset({ + ...data, + /* Convert null to undefined */ + path: data?.path || undefined, + port: data?.port || undefined, + }); + } + + if (!domainId) { + form.reset({}); + } + }, [form, form.reset, data, isLoading]); + + const dictionary = { + success: domainId ? "Domain Updated" : "Domain Created", + error: domainId + ? "Error to update the domain" + : "Error to create the domain", + submit: domainId ? "Update" : "Create", + dialogDescription: domainId + ? "In this section you can edit a domain" + : "In this section you can add domains", + }; + + const onSubmit = async (data: Domain) => { + await mutateAsync({ + domainId, + previewDeploymentId, + ...data, + }) + .then(async () => { + toast.success(dictionary.success); + await utils.previewDeployment.all.invalidate({ + applicationId: previewDeployment?.applicationId, + }); + + if (domainId) { + refetch(); + } + setIsOpen(false); + }) + .catch(() => { + toast.error(dictionary.error); + }); + }; + return ( + + + {children} + + + + Domain + {dictionary.dialogDescription} + + {isError && {error?.message}} + +
+ +
+
+ ( + + Host +
+ + + + + + + + + +

Generate traefik.me domain

+
+
+
+
+ + +
+ )} + /> + + { + return ( + + Path + + + + + + ); + }} + /> + + { + return ( + + Container Port + + + + + + ); + }} + /> + + ( + +
+ HTTPS + + Automatically provision SSL Certificate. + + +
+ + + +
+ )} + /> + + {form.getValues().https && ( + ( + + Certificate + + + + )} + /> + )} +
+
+
+ + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx new file mode 100644 index 00000000..4eb2107f --- /dev/null +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-builds.tsx @@ -0,0 +1,87 @@ +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import type { RouterOutputs } from "@/utils/api"; +import { useState } from "react"; +import { ShowDeployment } from "../deployments/show-deployment"; + +interface Props { + deployments: RouterOutputs["deployment"]["all"]; + serverId?: string; +} + +export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => { + const [activeLog, setActiveLog] = useState(null); + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + + + Preview Builds + + See all the preview builds for this application on this Pull Request + + +
+ {deployments?.map((deployment) => ( +
+
+ + {deployment.status} + + + + + {deployment.title} + + {deployment.description && ( + + {deployment.description} + + )} +
+
+
+ +
+ + +
+
+ ))} +
+
+ setActiveLog(null)} + logPath={activeLog} + /> +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx new file mode 100644 index 00000000..45451e78 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -0,0 +1,212 @@ +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; +import { Pencil, RocketIcon } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { ShowDeployment } from "../deployments/show-deployment"; +import Link from "next/link"; +import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { AddPreviewDomain } from "./add-preview-domain"; +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { ShowPreviewSettings } from "./show-preview-settings"; +import { ShowPreviewBuilds } from "./show-preview-builds"; + +interface Props { + applicationId: string; +} + +export const ShowPreviewDeployments = ({ applicationId }: Props) => { + const [activeLog, setActiveLog] = useState(null); + const { data } = api.application.one.useQuery({ applicationId }); + + const { mutateAsync: deletePreviewDeployment, isLoading } = + api.previewDeployment.delete.useMutation(); + const { data: previewDeployments, refetch: refetchPreviewDeployments } = + api.previewDeployment.all.useQuery( + { applicationId }, + { + enabled: !!applicationId, + }, + ); + // const [url, setUrl] = React.useState(""); + // useEffect(() => { + // setUrl(document.location.origin); + // }, []); + + return ( + + +
+ Preview Deployments + See all the preview deployments +
+ {data?.isPreviewDeploymentsActive && ( + + )} +
+ + {data?.isPreviewDeploymentsActive ? ( + <> +
+ + Preview deployments are a way to test your application before it + is deployed to production. It will create a new deployment for + each pull request you create. + +
+ {data?.previewDeployments?.length === 0 ? ( +
+ + + No preview deployments found + +
+ ) : ( +
+ {previewDeployments?.map((previewDeployment) => { + const { deployments, domain } = previewDeployment; + + return ( +
+
+
+ {deployments?.length === 0 ? ( +
+ + No deployments found + +
+ ) : ( +
+ + {previewDeployment?.pullRequestTitle} + + +
+ )} +
+ {previewDeployment?.pullRequestTitle && ( +
+ + Title: {previewDeployment?.pullRequestTitle} + +
+ )} + + {previewDeployment?.pullRequestURL && ( +
+ + + Pull Request URL + +
+ )} +
+
+ Domain +
+ + {domain?.host} + + + + +
+
+
+ +
+ {previewDeployment?.createdAt && ( +
+ +
+ )} + + + + + + + { + deletePreviewDeployment({ + previewDeploymentId: + previewDeployment.previewDeploymentId, + }) + .then(() => { + refetchPreviewDeployments(); + toast.success("Preview deployment deleted"); + }) + .catch((error) => { + toast.error(error.message); + }); + }} + > + + +
+
+
+ ); + })} +
+ )} + + ) : ( +
+ + + Preview deployments are disabled for this application, please + enable it + + +
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx new file mode 100644 index 00000000..6e56bbdd --- /dev/null +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -0,0 +1,351 @@ +import { api } from "@/utils/api"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input, NumberInput } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Secrets } from "@/components/ui/secrets"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const schema = z.object({ + env: z.string(), + buildArgs: z.string(), + wildcardDomain: z.string(), + port: z.number(), + previewLimit: z.number(), + previewHttps: z.boolean(), + previewPath: z.string(), + previewCertificateType: z.enum(["letsencrypt", "none"]), +}); + +type Schema = z.infer; + +interface Props { + applicationId: string; +} + +export const ShowPreviewSettings = ({ applicationId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + const { mutateAsync: updateApplication, isLoading } = + api.application.update.useMutation(); + + const { data, refetch } = api.application.one.useQuery({ applicationId }); + + const form = useForm({ + defaultValues: { + env: "", + wildcardDomain: "*.traefik.me", + port: 3000, + previewLimit: 3, + previewHttps: false, + previewPath: "/", + previewCertificateType: "none", + }, + resolver: zodResolver(schema), + }); + + const previewHttps = form.watch("previewHttps"); + + useEffect(() => { + setIsEnabled(data?.isPreviewDeploymentsActive || false); + }, [data?.isPreviewDeploymentsActive]); + + useEffect(() => { + if (data) { + form.reset({ + env: data.previewEnv || "", + buildArgs: data.previewBuildArgs || "", + wildcardDomain: data.previewWildcard || "*.traefik.me", + port: data.previewPort || 3000, + previewLimit: data.previewLimit || 3, + previewHttps: data.previewHttps || false, + previewPath: data.previewPath || "/", + previewCertificateType: data.previewCertificateType || "none", + }); + } + }, [data]); + + const onSubmit = async (formData: Schema) => { + updateApplication({ + previewEnv: formData.env, + previewBuildArgs: formData.buildArgs, + previewWildcard: formData.wildcardDomain, + previewPort: formData.port, + applicationId, + previewLimit: formData.previewLimit, + previewHttps: formData.previewHttps, + previewPath: formData.previewPath, + previewCertificateType: formData.previewCertificateType, + }) + .then(() => { + toast.success("Preview Deployments settings updated"); + }) + .catch((error) => { + toast.error(error.message); + }); + }; + return ( +
+ + + + + + + Preview Deployment Settings + + Adjust the settings for preview deployments of this application, + including environment variables, build options, and deployment + rules. + + +
+
+ +
+ ( + + Wildcard Domain + + + + + + )} + /> + ( + + Preview Path + + + + + + )} + /> + ( + + Port + + + + + + )} + /> + ( + + Preview Limit + {/* + Set the limit of preview deployments that can be + created for this app. + */} + + + + + + )} + /> + ( + +
+ HTTPS + + Automatically provision SSL Certificate. + + +
+ + + +
+ )} + /> + {previewHttps && ( + ( + + Certificate + + + + )} + /> + )} +
+
+
+
+ + Enable preview deployments + + + Enable or disable preview deployments for this + application. + +
+ { + updateApplication({ + isPreviewDeploymentsActive: checked, + applicationId, + }) + .then(() => { + refetch(); + toast.success("Preview deployments enabled"); + }) + .catch((error) => { + toast.error(error.message); + }); + }} + /> +
+
+ + ( + + + + {/* */} + + + + )} + /> + {data?.buildType === "dockerfile" && ( + + Available only at build-time. See documentation  + + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + + +
+ + + + +
+
+ {/* */} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 119d4d29..eb0d2255 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -33,6 +33,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { ShowDeployment } from "../../application/deployments/show-deployment"; import { GPUSupport } from "./gpu-support"; +import { ValidateServer } from "./validate-server"; interface Props { serverId: string; @@ -90,9 +91,10 @@ export const SetupServer = ({ serverId }: Props) => { ) : (
- + SSH Keys Deployments + Validate GPU Setup {
-
+
Deployments @@ -293,6 +295,14 @@ export const SetupServer = ({ serverId }: Props) => {
+ +
+ +
+
{ + const [isRefreshing, setIsRefreshing] = useState(false); + const { data, refetch, error, isLoading, isError } = + api.server.validate.useQuery( + { serverId }, + { + enabled: !!serverId, + }, + ); + const utils = api.useUtils(); + return ( + +
+ + +
+
+
+ + Setup Validation +
+ + Check if your server is ready for deployment + +
+ +
+
+ {isError && ( + + {error.message} + + )} +
+
+ + + {isLoading ? ( +
+ + Checking Server Configuration +
+ ) : ( +
+
+

Status

+

+ Shows the server configuration status +

+
+ + + + + + + +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/ui/secrets.tsx b/apps/dokploy/components/ui/secrets.tsx index a596ef1b..5669b051 100644 --- a/apps/dokploy/components/ui/secrets.tsx +++ b/apps/dokploy/components/ui/secrets.tsx @@ -29,7 +29,7 @@ export const Secrets = (props: Props) => { return ( <> - +
{props.title} {props.description} @@ -47,7 +47,7 @@ export const Secrets = (props: Props) => { )} - + statement-breakpoint +CREATE TABLE IF NOT EXISTS "preview_deployments" ( + "previewDeploymentId" text PRIMARY KEY NOT NULL, + "branch" text NOT NULL, + "pullRequestId" text NOT NULL, + "pullRequestNumber" text NOT NULL, + "pullRequestURL" text NOT NULL, + "pullRequestTitle" text NOT NULL, + "pullRequestCommentId" text NOT NULL, + "previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL, + "appName" text NOT NULL, + "applicationId" text NOT NULL, + "domainId" text, + "createdAt" text NOT NULL, + "expiresAt" text, + CONSTRAINT "preview_deployments_appName_unique" UNIQUE("appName") +); +--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewEnv" text;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewBuildArgs" text;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewWildcard" text;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewPort" integer DEFAULT 3000;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewHttps" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewPath" text DEFAULT '/';--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "certificateType" "certificateType" DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "previewLimit" integer DEFAULT 3;--> statement-breakpoint +ALTER TABLE "application" ADD COLUMN "isPreviewDeploymentsActive" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "domain" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint +ALTER TABLE "deployment" ADD COLUMN "isPreviewDeployment" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "deployment" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_domainId_domain_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domain"("domainId") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "domain" ADD CONSTRAINT "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "deployment" ADD CONSTRAINT "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/dokploy/drizzle/meta/0049_snapshot.json b/apps/dokploy/drizzle/meta/0049_snapshot.json index d03b2b90..7ef4e679 100644 --- a/apps/dokploy/drizzle/meta/0049_snapshot.json +++ b/apps/dokploy/drizzle/meta/0049_snapshot.json @@ -1,5 +1,5 @@ { - "id": "47638eb6-3fb5-4a1e-a0d1-911eb3256976", + "id": "db518175-259d-4f4a-b6d0-fe95067bba61", "prevId": "928417c8-2e7b-43ba-bc19-44b4d70107f1", "version": "6", "dialect": "postgresql", @@ -38,6 +38,67 @@ "primaryKey": false, "notNull": false }, + "previewEnv": { + "name": "previewEnv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewBuildArgs": { + "name": "previewBuildArgs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewWildcard": { + "name": "previewWildcard", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previewPort": { + "name": "previewPort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3000 + }, + "previewHttps": { + "name": "previewHttps", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previewPath": { + "name": "previewPath", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/'" + }, + "certificateType": { + "name": "certificateType", + "type": "certificateType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "previewLimit": { + "name": "previewLimit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "isPreviewDeploymentsActive": { + "name": "isPreviewDeploymentsActive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, "buildArgs": { "name": "buildArgs", "type": "text", @@ -1162,6 +1223,12 @@ "primaryKey": false, "notNull": false }, + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, "certificateType": { "name": "certificateType", "type": "certificateType", @@ -1198,6 +1265,19 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk": { + "name": "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk", + "tableFrom": "domain", + "tableTo": "preview_deployments", + "columnsFrom": [ + "previewDeploymentId" + ], + "columnsTo": [ + "previewDeploymentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -1967,6 +2047,19 @@ "primaryKey": false, "notNull": false }, + "isPreviewDeployment": { + "name": "isPreviewDeployment", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, "createdAt": { "name": "createdAt", "type": "text", @@ -2014,6 +2107,19 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk": { + "name": "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk", + "tableFrom": "deployment", + "tableTo": "preview_deployments", + "columnsFrom": [ + "previewDeploymentId" + ], + "columnsTo": [ + "previewDeploymentId" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -3641,13 +3747,6 @@ "primaryKey": true, "notNull": true }, - "gitlabUrl": { - "name": "gitlabUrl", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'https://gitlab.com'" - }, "application_id": { "name": "application_id", "type": "text", @@ -3828,6 +3927,131 @@ }, "compositePrimaryKeys": {}, "uniqueConstraints": {} + }, + "public.preview_deployments": { + "name": "preview_deployments", + "schema": "", + "columns": { + "previewDeploymentId": { + "name": "previewDeploymentId", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestId": { + "name": "pullRequestId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestNumber": { + "name": "pullRequestNumber", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestURL": { + "name": "pullRequestURL", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestTitle": { + "name": "pullRequestTitle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pullRequestCommentId": { + "name": "pullRequestCommentId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "previewStatus": { + "name": "previewStatus", + "type": "applicationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "appName": { + "name": "appName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domainId": { + "name": "domainId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "preview_deployments_applicationId_application_applicationId_fk": { + "name": "preview_deployments_applicationId_application_applicationId_fk", + "tableFrom": "preview_deployments", + "tableTo": "application", + "columnsFrom": [ + "applicationId" + ], + "columnsTo": [ + "applicationId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "preview_deployments_domainId_domain_domainId_fk": { + "name": "preview_deployments_domainId_domain_domainId_fk", + "tableFrom": "preview_deployments", + "tableTo": "domain", + "columnsFrom": [ + "domainId" + ], + "columnsTo": [ + "domainId" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preview_deployments_appName_unique": { + "name": "preview_deployments_appName_unique", + "nullsNotDistinct": false, + "columns": [ + "appName" + ] + } + } } }, "enums": { @@ -3867,7 +4091,8 @@ "schema": "public", "values": [ "compose", - "application" + "application", + "preview" ] }, "public.databaseType": { diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 76263c39..d952323d 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -348,8 +348,8 @@ { "idx": 49, "version": "6", - "when": 1733610324506, - "tag": "0049_fluffy_expediter", + "when": 1733628762978, + "tag": "0049_dark_leopardon", "breakpoints": true } ] diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index f1e9505f..292d3efb 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.13.1", + "version": "v0.14.1", "private": true, "license": "Apache-2.0", "type": "module", @@ -11,7 +11,8 @@ "build-next": "next build", "setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run", "reset-password": "node -r dotenv/config dist/reset-password.mjs", - "dev": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ", + "dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ", + "dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json", "studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts", "migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts", "migration:run": "tsx -r dotenv/config migration.ts", diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index e5ed154f..0e0a6c82 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -3,11 +3,19 @@ import { applications, compose, github } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { myQueue } from "@/server/queues/queueSetup"; import { deploy } from "@/server/utils/deploy"; -import { IS_CLOUD } from "@dokploy/server"; +import { + createPreviewDeployment, + type Domain, + findPreviewDeploymentByApplicationId, + findPreviewDeploymentsByPullRequestId, + IS_CLOUD, + removePreviewDeployment, +} from "@dokploy/server"; import { Webhooks } from "@octokit/webhooks"; import { and, eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { extractCommitMessage, extractHash } from "./[refreshToken]"; +import { generateRandomDomain } from "@/templates/utils"; export default async function handler( req: NextApiRequest, @@ -53,36 +61,183 @@ export default async function handler( return; } - if (req.headers["x-github-event"] !== "push") { - res.status(400).json({ message: "We only accept push events" }); + if ( + req.headers["x-github-event"] !== "push" && + req.headers["x-github-event"] !== "pull_request" + ) { + res + .status(400) + .json({ message: "We only accept push events or pull_request events" }); return; } - try { - const branchName = githubBody?.ref?.replace("refs/heads/", ""); + if (req.headers["x-github-event"] === "push") { + try { + const branchName = githubBody?.ref?.replace("refs/heads/", ""); + const repository = githubBody?.repository?.name; + const deploymentTitle = extractCommitMessage(req.headers, req.body); + const deploymentHash = extractHash(req.headers, req.body); + const owner = githubBody?.repository?.owner?.name; + + 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), + eq(applications.owner, owner), + ), + }); + + for (const app of apps) { + const jobData: DeploymentJob = { + applicationId: app.applicationId as string, + titleLog: deploymentTitle, + descriptionLog: `Hash: ${deploymentHash}`, + type: "deploy", + applicationType: "application", + server: !!app.serverId, + }; + + if (IS_CLOUD && app.serverId) { + jobData.serverId = app.serverId; + await deploy(jobData); + return true; + } + 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), + eq(compose.owner, owner), + ), + }); + + for (const composeApp of composeApps) { + const jobData: DeploymentJob = { + composeId: composeApp.composeId as string, + titleLog: deploymentTitle, + type: "deploy", + applicationType: "compose", + descriptionLog: `Hash: ${deploymentHash}`, + server: !!composeApp.serverId, + }; + + if (IS_CLOUD && composeApp.serverId) { + jobData.serverId = composeApp.serverId; + await deploy(jobData); + return true; + } + + 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 }); + } + } else if (req.headers["x-github-event"] === "pull_request") { + const prId = githubBody?.pull_request?.id; + + if (githubBody?.action === "closed") { + const previewDeploymentResult = + await findPreviewDeploymentsByPullRequestId(prId); + + if (previewDeploymentResult.length > 0) { + for (const previewDeployment of previewDeploymentResult) { + try { + await removePreviewDeployment( + previewDeployment.previewDeploymentId, + ); + } catch (error) { + console.log(error); + } + } + } + res.status(200).json({ message: "Preview Deployment Closed" }); + return; + } + // opened or synchronize or reopened const repository = githubBody?.repository?.name; - const deploymentTitle = extractCommitMessage(req.headers, req.body); - const deploymentHash = extractHash(req.headers, req.body); - const owner = githubBody?.repository?.owner?.name; + const deploymentHash = githubBody?.pull_request?.head?.sha; + const branch = githubBody?.pull_request?.base?.ref; + const owner = githubBody?.repository?.owner?.login; 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), + eq(applications.branch, branch), + eq(applications.isPreviewDeploymentsActive, true), eq(applications.owner, owner), ), + with: { + previewDeployments: true, + }, }); + const prBranch = githubBody?.pull_request?.head?.ref; + + const prNumber = githubBody?.pull_request?.number; + const prTitle = githubBody?.pull_request?.title; + const prURL = githubBody?.pull_request?.html_url; + for (const app of apps) { + const previewLimit = app?.previewLimit || 0; + if (app?.previewDeployments?.length > previewLimit) { + continue; + } + const previewDeploymentResult = + await findPreviewDeploymentByApplicationId(app.applicationId, prId); + + let previewDeploymentId = + previewDeploymentResult?.previewDeploymentId || ""; + + if (!previewDeploymentResult) { + const previewDeployment = await createPreviewDeployment({ + applicationId: app.applicationId as string, + branch: prBranch, + pullRequestId: prId, + pullRequestNumber: prNumber, + pullRequestTitle: prTitle, + pullRequestURL: prURL, + }); + previewDeploymentId = previewDeployment.previewDeploymentId; + } + const jobData: DeploymentJob = { applicationId: app.applicationId as string, - titleLog: deploymentTitle, + titleLog: "Preview Deployment", descriptionLog: `Hash: ${deploymentHash}`, type: "deploy", - applicationType: "application", + applicationType: "application-preview", server: !!app.serverId, + previewDeploymentId, }; if (IS_CLOUD && app.serverId) { @@ -99,52 +254,8 @@ export default async function handler( }, ); } - - 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), - eq(compose.owner, owner), - ), - }); - - for (const composeApp of composeApps) { - const jobData: DeploymentJob = { - composeId: composeApp.composeId as string, - titleLog: deploymentTitle, - type: "deploy", - applicationType: "compose", - descriptionLog: `Hash: ${deploymentHash}`, - server: !!composeApp.serverId, - }; - - if (IS_CLOUD && composeApp.serverId) { - jobData.serverId = composeApp.serverId; - await deploy(jobData); - return true; - } - - 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 }); + return res.status(200).json({ message: "Apps Deployed" }); } + + return res.status(400).json({ message: "No Actions matched" }); } diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx index 156e1973..bcbd4b78 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/application/[applicationId].tsx @@ -12,6 +12,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom import { ShowEnvironment } from "@/components/dashboard/application/environment/show"; import { ShowGeneralApplication } from "@/components/dashboard/application/general/show"; import { ShowDockerLogs } from "@/components/dashboard/application/logs/show"; +import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments"; import { UpdateApplication } from "@/components/dashboard/application/update-application"; import { DockerMonitoring } from "@/components/dashboard/monitoring/docker/show"; import { ProjectLayout } from "@/components/layouts/project-layout"; @@ -51,7 +52,8 @@ type TabState = | "advanced" | "deployments" | "domains" - | "monitoring"; + | "monitoring" + | "preview-deployments"; const Service = ( props: InferGetServerSidePropsType, @@ -191,8 +193,8 @@ const Service = (
General @@ -202,6 +204,9 @@ const Service = ( )} Logs Deployments + + Preview Deployments + Domains Advanced @@ -244,6 +249,11 @@ const Service = (
+ +
+ +
+
diff --git a/apps/dokploy/public/templates/browserless.svg b/apps/dokploy/public/templates/browserless.svg new file mode 100644 index 00000000..35023f7d --- /dev/null +++ b/apps/dokploy/public/templates/browserless.svg @@ -0,0 +1,13 @@ + + favicon + + + + + + + + + \ No newline at end of file diff --git a/apps/dokploy/public/templates/budibase.svg b/apps/dokploy/public/templates/budibase.svg new file mode 100644 index 00000000..26d09cc9 --- /dev/null +++ b/apps/dokploy/public/templates/budibase.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/dokploy/public/templates/drawio.svg b/apps/dokploy/public/templates/drawio.svg new file mode 100644 index 00000000..07909528 --- /dev/null +++ b/apps/dokploy/public/templates/drawio.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/dokploy/public/templates/kimai.svg b/apps/dokploy/public/templates/kimai.svg new file mode 100644 index 00000000..8b2a6c1e --- /dev/null +++ b/apps/dokploy/public/templates/kimai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 1b67d350..85eb9763 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -31,6 +31,7 @@ import { settingsRouter } from "./routers/settings"; import { sshRouter } from "./routers/ssh-key"; import { stripeRouter } from "./routers/stripe"; import { userRouter } from "./routers/user"; +import { previewDeploymentRouter } from "./routers/preview-deployment"; /** * This is the primary router for your server. @@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({ destination: destinationRouter, backup: backupRouter, deployment: deploymentRouter, + previewDeployment: previewDeploymentRouter, mounts: mountRouter, certificates: certificateRouter, settings: settingsRouter, diff --git a/apps/dokploy/server/api/routers/domain.ts b/apps/dokploy/server/api/routers/domain.ts index 3c94fc93..b0a1000c 100644 --- a/apps/dokploy/server/api/routers/domain.ts +++ b/apps/dokploy/server/api/routers/domain.ts @@ -13,6 +13,7 @@ import { findDomainById, findDomainsByApplicationId, findDomainsByComposeId, + findPreviewDeploymentById, generateTraefikMeDomain, manageDomain, removeDomain, @@ -108,12 +109,33 @@ export const domainRouter = createTRPCRouter({ message: "You are not authorized to access this compose", }); } + } else if (currentDomain.previewDeploymentId) { + const newPreviewDeployment = await findPreviewDeploymentById( + currentDomain.previewDeploymentId, + ); + if ( + newPreviewDeployment.application.project.adminId !== ctx.user.adminId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this preview deployment", + }); + } } const result = await updateDomainById(input.domainId, input); const domain = await findDomainById(input.domainId); if (domain.applicationId) { const application = await findApplicationById(domain.applicationId); await manageDomain(application, domain); + } else if (domain.previewDeploymentId) { + const previewDeployment = await findPreviewDeploymentById( + domain.previewDeploymentId, + ); + const application = await findApplicationById( + previewDeployment.applicationId, + ); + application.appName = previewDeployment.appName; + await manageDomain(application, domain); } return result; }), diff --git a/apps/dokploy/server/api/routers/preview-deployment.ts b/apps/dokploy/server/api/routers/preview-deployment.ts new file mode 100644 index 00000000..74b8461a --- /dev/null +++ b/apps/dokploy/server/api/routers/preview-deployment.ts @@ -0,0 +1,54 @@ +import { apiFindAllByApplication } from "@/server/db/schema"; +import { + findApplicationById, + findPreviewDeploymentById, + findPreviewDeploymentsByApplicationId, + removePreviewDeployment, +} from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const previewDeploymentRouter = createTRPCRouter({ + all: protectedProcedure + .input(apiFindAllByApplication) + .query(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if (application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this application", + }); + } + return await findPreviewDeploymentsByApplicationId(input.applicationId); + }), + delete: protectedProcedure + .input(z.object({ previewDeploymentId: z.string() })) + .mutation(async ({ input, ctx }) => { + const previewDeployment = await findPreviewDeploymentById( + input.previewDeploymentId, + ); + if (previewDeployment.application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to delete this preview deployment", + }); + } + await removePreviewDeployment(input.previewDeploymentId); + return true; + }), + one: protectedProcedure + .input(z.object({ previewDeploymentId: z.string() })) + .query(async ({ input, ctx }) => { + const previewDeployment = await findPreviewDeploymentById( + input.previewDeploymentId, + ); + if (previewDeployment.application.project.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this preview deployment", + }); + } + return previewDeployment; + }), +}); diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index 0d4ef87f..f0ea4fed 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -26,6 +26,7 @@ import { haveActiveServices, removeDeploymentsByServerId, serverSetup, + serverValidate, updateServerById, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; @@ -118,6 +119,47 @@ export const serverRouter = createTRPCRouter({ throw error; } }), + validate: protectedProcedure + .input(apiFindOneServer) + .query(async ({ input, ctx }) => { + try { + const server = await findServerById(input.serverId); + if (server.adminId !== ctx.user.adminId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to validate this server", + }); + } + const response = await serverValidate(input.serverId); + return response as unknown as { + docker: { + enabled: boolean; + version: string; + }; + rclone: { + enabled: boolean; + version: string; + }; + nixpacks: { + enabled: boolean; + version: string; + }; + buildpacks: { + enabled: boolean; + version: string; + }; + isDokployNetworkInstalled: boolean; + isSwarmInstalled: boolean; + isMainDirectoryInstalled: boolean; + }; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: error instanceof Error ? error?.message : `Error: ${error}`, + cause: error as Error, + }); + } + }), remove: protectedProcedure .input(apiRemoveServer) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/queues/deployments-queue.ts b/apps/dokploy/server/queues/deployments-queue.ts index 08e0c9a1..b8dfb8cd 100644 --- a/apps/dokploy/server/queues/deployments-queue.ts +++ b/apps/dokploy/server/queues/deployments-queue.ts @@ -1,14 +1,17 @@ import { deployApplication, deployCompose, + deployPreviewApplication, deployRemoteApplication, deployRemoteCompose, + deployRemotePreviewApplication, rebuildApplication, rebuildCompose, rebuildRemoteApplication, rebuildRemoteCompose, updateApplicationStatus, updateCompose, + updatePreviewDeployment, } from "@dokploy/server"; import { type Job, Worker } from "bullmq"; import type { DeploymentJob } from "./queue-types"; @@ -20,6 +23,7 @@ export const deploymentWorker = new Worker( try { if (job.data.applicationType === "application") { await updateApplicationStatus(job.data.applicationId, "running"); + if (job.data.server) { if (job.data.type === "redeploy") { await rebuildRemoteApplication({ @@ -83,6 +87,29 @@ export const deploymentWorker = new Worker( }); } } + } else if (job.data.applicationType === "application-preview") { + await updatePreviewDeployment(job.data.previewDeploymentId, { + previewStatus: "running", + }); + if (job.data.server) { + if (job.data.type === "deploy") { + await deployRemotePreviewApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + previewDeploymentId: job.data.previewDeploymentId, + }); + } + } else { + if (job.data.type === "deploy") { + await deployPreviewApplication({ + applicationId: job.data.applicationId, + titleLog: job.data.titleLog, + descriptionLog: job.data.descriptionLog, + previewDeploymentId: job.data.previewDeploymentId, + }); + } + } } } catch (error) { console.log("Error", error); diff --git a/apps/dokploy/server/queues/queue-types.ts b/apps/dokploy/server/queues/queue-types.ts index f467836a..ef8df694 100644 --- a/apps/dokploy/server/queues/queue-types.ts +++ b/apps/dokploy/server/queues/queue-types.ts @@ -16,6 +16,16 @@ type DeployJob = type: "deploy" | "redeploy"; applicationType: "compose"; serverId?: string; + } + | { + applicationId: string; + titleLog: string; + descriptionLog: string; + server?: boolean; + type: "deploy"; + applicationType: "application-preview"; + previewDeploymentId: string; + serverId?: string; }; export type DeploymentJob = DeployJob; diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index b65446f8..b13e5df5 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -24,7 +24,7 @@ import { setupTerminalWebSocketServer } from "./wss/terminal"; config({ path: ".env" }); const PORT = Number.parseInt(process.env.PORT || "3000", 10); const dev = process.env.NODE_ENV !== "production"; -const app = next({ dev, turbopack: dev }); +const app = next({ dev, turbopack: process.env.TURBOPACK === "1" }); const handle = app.getRequestHandler(); void app.prepare().then(async () => { try { diff --git a/apps/dokploy/templates/browserless/docker-compose.yml b/apps/dokploy/templates/browserless/docker-compose.yml new file mode 100644 index 00000000..11d6d95f --- /dev/null +++ b/apps/dokploy/templates/browserless/docker-compose.yml @@ -0,0 +1,16 @@ +services: + browserless: + image: ghcr.io/browserless/chromium:v2.23.0 + environment: + TOKEN: ${BROWSERLESS_TOKEN} + expose: + - 3000 + healthcheck: + test: + - CMD + - curl + - '-f' + - 'http://127.0.0.1:3000/docs' + interval: 2s + timeout: 10s + retries: 15 diff --git a/apps/dokploy/templates/browserless/index.ts b/apps/dokploy/templates/browserless/index.ts new file mode 100644 index 00000000..f922e863 --- /dev/null +++ b/apps/dokploy/templates/browserless/index.ts @@ -0,0 +1,28 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const mainHost = generateRandomDomain(schema); + + const domains: DomainSchema[] = [ + { + host: mainHost, + port: 3000, + serviceName: "browserless", + }, + ]; + const envs = [ + `BROWERLESS_HOST=${mainHost}`, + `BROWSERLESS_TOKEN=${generatePassword(16)}`, + ]; + + return { + envs, + domains, + }; +} diff --git a/apps/dokploy/templates/budibase/docker-compose.yml b/apps/dokploy/templates/budibase/docker-compose.yml new file mode 100644 index 00000000..3f82de3e --- /dev/null +++ b/apps/dokploy/templates/budibase/docker-compose.yml @@ -0,0 +1,199 @@ +services: + apps: + image: budibase.docker.scarf.sh/budibase/apps:3.2.25 + restart: unless-stopped + networks: + - dokploy-network + environment: + SELF_HOSTED: 1 + LOG_LEVEL: info + PORT: 4002 + INTERNAL_API_KEY: ${BB_INTERNAL_API_KEY} + API_ENCRYPTION_KEY: ${BB_API_ENCRYPTION_KEY} + JWT_SECRET: ${BB_JWT_SECRET} + MINIO_ACCESS_KEY: ${BB_MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${BB_MINIO_SECRET_KEY} + MINIO_URL: http://minio:9000 + REDIS_URL: redis:6379 + REDIS_PASSWORD: ${BB_REDIS_PASSWORD} + WORKER_URL: http://worker:4003 + COUCH_DB_USERNAME: budibase + COUCH_DB_PASSWORD: ${BB_COUCHDB_PASSWORD} + COUCH_DB_URL: http://budibase:${BB_COUCHDB_PASSWORD}@couchdb:5984 + BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT:-PRODUCTION} + ENABLE_ANALYTICS: ${ENABLE_ANALYTICS:-true} + BB_ADMIN_USER_EMAIL: '' + BB_ADMIN_USER_PASSWORD: '' + depends_on: + worker: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + - CMD + - wget + - '--spider' + - '-qO-' + - 'http://localhost:4002/health' + interval: 15s + timeout: 15s + retries: 5 + start_period: 10s + worker: + image: budibase.docker.scarf.sh/budibase/worker:3.2.25 + restart: unless-stopped + networks: + - dokploy-network + environment: + SELF_HOSTED: 1 + LOG_LEVEL: info + PORT: 4003 + CLUSTER_PORT: 10000 + INTERNAL_API_KEY: ${BB_INTERNAL_API_KEY} + API_ENCRYPTION_KEY: ${BB_API_ENCRYPTION_KEY} + JWT_SECRET: ${BB_JWT_SECRET} + MINIO_ACCESS_KEY: ${BB_MINIO_ACCESS_KEY} + MINIO_SECRET_KEY: ${BB_MINIO_SECRET_KEY} + APPS_URL: http://apps:4002 + MINIO_URL: http://minio:9000 + REDIS_URL: redis:6379 + REDIS_PASSWORD: ${BB_REDIS_PASSWORD} + COUCH_DB_USERNAME: budibase + COUCH_DB_PASSWORD: ${BB_COUCHDB_PASSWORD} + COUCH_DB_URL: http://budibase:${BB_COUCHDB_PASSWORD}@couchdb:5984 + BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT:-PRODUCTION} + ENABLE_ANALYTICS: ${ENABLE_ANALYTICS:-true} + depends_on: + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: + - CMD + - wget + - '--spider' + - '-qO-' + - 'http://localhost:4003/health' + interval: 15s + timeout: 15s + retries: 5 + start_period: 10s + minio: + image: minio/minio:RELEASE.2024-11-07T00-52-20Z + restart: unless-stopped + networks: + - dokploy-network + volumes: + - 'minio_data:/data' + environment: + MINIO_ROOT_USER: ${BB_MINIO_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${BB_MINIO_SECRET_KEY} + MINIO_BROWSER: off + command: 'server /data --console-address ":9001"' + healthcheck: + test: + - CMD + - curl + - '-f' + - 'http://localhost:9000/minio/health/live' + interval: 30s + timeout: 20s + retries: 3 + proxy: + image: budibase/proxy:3.2.25 + restart: unless-stopped + networks: + - dokploy-network + environment: + PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND: 10 + PROXY_RATE_LIMIT_API_PER_SECOND: 20 + APPS_UPSTREAM_URL: http://apps:4002 + WORKER_UPSTREAM_URL: http://worker:4003 + MINIO_UPSTREAM_URL: http://minio:9000 + COUCHDB_UPSTREAM_URL: http://couchdb:5984 + WATCHTOWER_UPSTREAM_URL: http://watchtower:8080 + RESOLVER: 127.0.0.11 + depends_on: + minio: + condition: service_healthy + worker: + condition: service_healthy + apps: + condition: service_healthy + couchdb: + condition: service_healthy + healthcheck: + test: + - CMD + - curl + - '-f' + - 'http://localhost:10000/' + interval: 15s + timeout: 15s + retries: 5 + start_period: 10s + couchdb: + image: budibase/couchdb:v3.3.3 + restart: unless-stopped + networks: + - dokploy-network + environment: + COUCHDB_USER: budibase + COUCHDB_PASSWORD: ${BB_COUCHDB_PASSWORD} + TARGETBUILD: docker-compose + healthcheck: + test: + - CMD + - curl + - '-f' + - 'http://localhost:5984/' + interval: 15s + timeout: 15s + retries: 5 + start_period: 10s + volumes: + - 'couchdb3_data:/opt/couchdb/data' + redis: + image: redis:7.2-alpine + networks: + - dokploy-network + restart: unless-stopped + command: 'redis-server --requirepass "${BB_REDIS_PASSWORD}"' + volumes: + - 'redis_data:/data' + healthcheck: + test: + - CMD + - redis-cli + - '-a' + - ${BB_REDIS_PASSWORD} + - ping + interval: 15s + timeout: 15s + retries: 5 + start_period: 10s + watchtower: + restart: unless-stopped + networks: + - dokploy-network + image: containrrr/watchtower:1.7.1 + volumes: + - '/var/run/docker.sock:/var/run/docker.sock' + command: '--debug --http-api-update bbapps bbworker bbproxy' + environment: + WATCHTOWER_HTTP_API: true + WATCHTOWER_HTTP_API_TOKEN: ${BB_WATCHTOWER_PASSWORD} + WATCHTOWER_CLEANUP: true + labels: + - com.centurylinklabs.watchtower.enable=false + +networks: + dokploy-network: + external: true + +volumes: + minio_data: + couchdb3_data: + redis_data: \ No newline at end of file diff --git a/apps/dokploy/templates/budibase/index.ts b/apps/dokploy/templates/budibase/index.ts new file mode 100644 index 00000000..50bdfdba --- /dev/null +++ b/apps/dokploy/templates/budibase/index.ts @@ -0,0 +1,45 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const mainDomain = generateRandomDomain(schema); + + const apiKey = generatePassword(32); + const encryptionKey = generatePassword(32); + const jwtSecret = generatePassword(32); + const couchDbPassword = generatePassword(32); + const redisPassword = generatePassword(32); + const minioAccessKey = generatePassword(32); + const minioSecretKey = generatePassword(32); + const watchtowerPassword = generatePassword(32); + + const domains: DomainSchema[] = [ + { + host: mainDomain, + port: 10000, + serviceName: "proxy", + }, + ]; + + const envs = [ + `BB_HOST=${mainDomain}`, + `BB_INTERNAL_API_KEY=${apiKey}`, + `BB_API_ENCRYPTION_KEY=${encryptionKey}`, + `BB_JWT_SECRET=${jwtSecret}`, + `BB_COUCHDB_PASSWORD=${couchDbPassword}`, + `BB_REDIS_PASSWORD=${redisPassword}`, + `BB_WATCHTOWER_PASSWORD=${watchtowerPassword}`, + `BB_MINIO_ACCESS_KEY=${minioAccessKey}`, + `BB_MINIO_SECRET_KEY=${minioSecretKey}`, + ]; + + return { + domains, + envs, + }; +} diff --git a/apps/dokploy/templates/drawio/docker-compose.yml b/apps/dokploy/templates/drawio/docker-compose.yml new file mode 100644 index 00000000..1712363f --- /dev/null +++ b/apps/dokploy/templates/drawio/docker-compose.yml @@ -0,0 +1,62 @@ +version: '3' +services: + plantuml-server: + image: plantuml/plantuml-server + ports: + - "8080" + networks: + - dokploy-network + volumes: + - fonts_volume:/usr/share/fonts/drawio + image-export: + image: jgraph/export-server + ports: + - "8000" + networks: + - dokploy-network + volumes: + - fonts_volume:/usr/share/fonts/drawio + environment: + - DRAWIO_BASE_URL=${DRAWIO_BASE_URL} + drawio: + image: jgraph/drawio:24.7.17 + ports: + - "8080" + links: + - plantuml-server:plantuml-server + - image-export:image-export + depends_on: + - plantuml-server + - image-export + networks: + - dokploy-network + environment: + RAWIO_SELF_CONTAINED: 1 + DRAWIO_USE_HTTP: 1 + PLANTUML_URL: http://plantuml-server:8080/ + EXPORT_URL: http://image-export:8000/ + DRAWIO_BASE_URL: ${DRAWIO_BASE_URL} + DRAWIO_SERVER_URL: ${DRAWIO_SERVER_URL} + DRAWIO_CSP_HEADER: ${DRAWIO_CSP_HEADER} + DRAWIO_VIEWER_URL: ${DRAWIO_VIEWER_URL} + DRAWIO_LIGHTBOX_URL: ${DRAWIO_LIGHTBOX_URL} + DRAWIO_CONFIG: ${DRAWIO_CONFIG} + DRAWIO_GOOGLE_CLIENT_ID: ${DRAWIO_GOOGLE_CLIENT_ID} + DRAWIO_GOOGLE_APP_ID: ${DRAWIO_GOOGLE_APP_ID} + DRAWIO_GOOGLE_CLIENT_SECRET: ${DRAWIO_GOOGLE_CLIENT_SECRET} + DRAWIO_GOOGLE_VIEWER_CLIENT_ID: ${DRAWIO_GOOGLE_VIEWER_CLIENT_ID} + DRAWIO_GOOGLE_VIEWER_APP_ID: ${DRAWIO_GOOGLE_VIEWER_APP_ID} + DRAWIO_GOOGLE_VIEWER_CLIENT_SECRET: ${DRAWIO_GOOGLE_VIEWER_CLIENT_SECRET} + DRAWIO_MSGRAPH_CLIENT_ID: ${DRAWIO_MSGRAPH_CLIENT_ID} + DRAWIO_MSGRAPH_CLIENT_SECRET: ${DRAWIO_MSGRAPH_CLIENT_SECRET} + DRAWIO_MSGRAPH_TENANT_ID: ${DRAWIO_MSGRAPH_TENANT_ID} + DRAWIO_GITLAB_ID: ${DRAWIO_GITLAB_ID} + DRAWIO_GITLAB_SECRET: ${DRAWIO_GITLAB_SECRET} + DRAWIO_GITLAB_URL: ${DRAWIO_GITLAB_URL} + DRAWIO_CLOUD_CONVERT_APIKEY: ${DRAWIO_CLOUD_CONVERT_APIKEY} +networks: + dokploy-network: + external: true + +volumes: + fonts_volume: \ No newline at end of file diff --git a/apps/dokploy/templates/drawio/index.ts b/apps/dokploy/templates/drawio/index.ts new file mode 100644 index 00000000..e3c57c5a --- /dev/null +++ b/apps/dokploy/templates/drawio/index.ts @@ -0,0 +1,31 @@ +import { + type DomainSchema, + type Schema, + type Template, + generateBase64, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const mainDomain = generateRandomDomain(schema); + const secretKeyBase = generateBase64(64); + + const domains: DomainSchema[] = [ + { + host: mainDomain, + port: 8080, + serviceName: "drawio", + }, + ]; + + const envs = [ + `DRAWIO_HOST=${mainDomain}`, + `DRAWIO_BASE_URL=https://${mainDomain}`, + `DRAWIO_SERVER_URL=https://${mainDomain}/`, + ]; + + return { + envs, + domains, + }; +} diff --git a/apps/dokploy/templates/kimai/docker-compose.yml b/apps/dokploy/templates/kimai/docker-compose.yml new file mode 100644 index 00000000..6a04b3b9 --- /dev/null +++ b/apps/dokploy/templates/kimai/docker-compose.yml @@ -0,0 +1,51 @@ +services: + app: + image: kimai/kimai2:apache-2.26.0 + restart: unless-stopped + environment: + APP_ENV: prod + DATABASE_URL: mysql://kimai:${KI_MYSQL_PASSWORD:-kimai}@db/kimai + TRUSTED_PROXIES: localhost + APP_SECRET: ${KI_APP_SECRET} + MAILER_FROM: ${KI_MAILER_FROM:-admin@kimai.local} + MAILER_URL: ${KI_MAILER_URL:-null://null} + ADMINMAIL: ${KI_ADMINMAIL:-admin@kimai.local} + ADMINPASS: ${KI_ADMINPASS} + volumes: + - kimai-data:/opt/kimai/var + depends_on: + db: + condition: service_healthy + networks: + - dokploy-network + db: + image: mariadb:10.11 + restart: unless-stopped + environment: + - MYSQL_DATABASE=kimai + - MYSQL_USER=kimai + - MYSQL_PASSWORD=${KI_MYSQL_PASSWORD} + - MYSQL_ROOT_PASSWORD=${KI_MYSQL_ROOT_PASSWORD} + volumes: + - mysql-data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --innodb-buffer-pool-size=256M + - --innodb-flush-log-at-trx-commit=2 + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "kimai", "-p${KI_MYSQL_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - dokploy-network + +networks: + dokploy-network: + external: true + +volumes: + kimai-data: + mysql-data: \ No newline at end of file diff --git a/apps/dokploy/templates/kimai/index.ts b/apps/dokploy/templates/kimai/index.ts new file mode 100644 index 00000000..5569905e --- /dev/null +++ b/apps/dokploy/templates/kimai/index.ts @@ -0,0 +1,37 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const domain = generateRandomDomain(schema); + const domains: DomainSchema[] = [ + { + host: domain, + port: 8001, + serviceName: "app", + }, + ]; + + const adminPassword = generatePassword(32); + const mysqlPassword = generatePassword(32); + const mysqlRootPassword = generatePassword(32); + const appSecret = generatePassword(32); + + const envs = [ + `KI_HOST=${domain}`, + "KI_ADMINMAIL=admin@kimai.local", + `KI_ADMINPASS=${adminPassword}`, + `KI_MYSQL_ROOT_PASSWORD=${mysqlRootPassword}`, + `KI_MYSQL_PASSWORD=${mysqlPassword}`, + `KI_APP_SECRET=${appSecret}`, + ]; + + return { + envs, + domains, + }; +} diff --git a/apps/dokploy/templates/templates.ts b/apps/dokploy/templates/templates.ts index ef418d2b..79d0b479 100644 --- a/apps/dokploy/templates/templates.ts +++ b/apps/dokploy/templates/templates.ts @@ -107,6 +107,21 @@ export const templates: TemplateData[] = [ tags: ["database"], load: () => import("./baserow/index").then((m) => m.generate), }, + { + id: "budibase", + name: "Budibase", + version: "3.2.25", + description: + "Budibase is an open-source low-code platform that saves engineers 100s of hours building forms, portals, and approval apps, securely.", + logo: "budibase.svg", + links: { + github: "https://github.com/Budibase/budibase", + website: "https://budibase.com/", + docs: "https://docs.budibase.com/docs/", + }, + tags: ["database", "low-code", "nocode", "applications"], + load: () => import("./budibase/index").then((m) => m.generate), + }, { id: "ghost", name: "Ghost", @@ -936,8 +951,8 @@ export const templates: TemplateData[] = [ logo: "ryot.png", links: { github: "https://github.com/IgnisDa/ryot", - website: "https://ryot.dev/", - docs: "https://ryot.dev/docs/getting-started", + website: "https://ryot.io/", + docs: "https://docs.ryot.io/", }, tags: ["media", "tracking", "self-hosted"], load: () => import("./ryot/index").then((m) => m.generate), @@ -987,4 +1002,49 @@ export const templates: TemplateData[] = [ tags: ["event-driven", "applications"], load: () => import("./triggerdotdev/index").then((m) => m.generate), }, + { + id: "browserless", + name: "Browserless", + version: "2.23.0", + description: + "Browserless allows remote clients to connect and execute headless work, all inside of docker. It supports the standard, unforked Puppeteer and Playwright libraries, as well offering REST-based APIs for common actions like data collection, PDF generation and more.", + logo: "browserless.svg", + links: { + github: "https://github.com/browserless/browserless", + website: "https://www.browserless.io/", + docs: "https://docs.browserless.io/", + }, + tags: ["browser", "automation"], + load: () => import("./browserless/index").then((m) => m.generate), + }, + { + id: "drawio", + name: "draw.io", + version: "24.7.17", + description: + "draw.io is a configurable diagramming/whiteboarding visualization application.", + logo: "drawio.svg", + links: { + github: "https://github.com/jgraph/drawio", + website: "https://draw.io/", + docs: "https://www.drawio.com/doc/", + }, + tags: ["drawing", "diagrams"], + load: () => import("./drawio/index").then((m) => m.generate), + }, + { + id: "kimai", + name: "Kimai", + version: "2.26.0", + description: + "Kimai is a web-based multi-user time-tracking application. Works great for everyone: freelancers, companies, organizations - everyone can track their times, generate reports, create invoices and do so much more.", + logo: "kimai.svg", + links: { + github: "https://github.com/kimai/kimai", + website: "https://www.kimai.org", + docs: "https://www.kimai.org/documentation", + }, + tags: ["invoice", "business", "finance"], + load: () => import("./kimai/index").then((m) => m.generate), + }, ]; diff --git a/package.json b/package.json index 94d176a6..f520707d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dokploy:setup": "pnpm --filter=dokploy run setup", "dokploy:dev": "pnpm --filter=dokploy run dev", + "dokploy:dev:turbopack": "pnpm --filter=dokploy run dev-turbopack", "dokploy:build": "pnpm --filter=dokploy run build", "dokploy:start": "pnpm --filter=dokploy run start", "test": "pnpm --filter=dokploy run test", diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 818a922d..d9b1a5df 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -22,9 +22,10 @@ import { redirects } from "./redirects"; import { registry } from "./registry"; import { security } from "./security"; import { server } from "./server"; -import { applicationStatus } from "./shared"; +import { applicationStatus, certificateType } from "./shared"; import { sshKeys } from "./ssh-key"; import { generateAppName } from "./utils"; +import { previewDeployments } from "./preview-deployments"; export const sourceType = pgEnum("sourceType", [ "docker", @@ -114,6 +115,19 @@ export const applications = pgTable("application", { .unique(), description: text("description"), env: text("env"), + previewEnv: text("previewEnv"), + previewBuildArgs: text("previewBuildArgs"), + previewWildcard: text("previewWildcard"), + previewPort: integer("previewPort").default(3000), + previewHttps: boolean("previewHttps").notNull().default(false), + previewPath: text("previewPath").default("/"), + previewCertificateType: certificateType("certificateType") + .notNull() + .default("none"), + previewLimit: integer("previewLimit").default(3), + isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default( + false, + ), buildArgs: text("buildArgs"), memoryReservation: integer("memoryReservation"), memoryLimit: integer("memoryLimit"), @@ -240,6 +254,7 @@ export const applicationsRelations = relations( fields: [applications.serverId], references: [server.serverId], }), + previewDeployments: many(previewDeployments), }), ); @@ -349,6 +364,7 @@ const createSchema = createInsertSchema(applications, { subtitle: z.string().optional(), dockerImage: z.string().optional(), username: z.string().optional(), + isPreviewDeploymentsActive: z.boolean().optional(), password: z.string().optional(), registryUrl: z.string().optional(), customGitSSHKeyId: z.string().optional(), @@ -380,6 +396,14 @@ const createSchema = createInsertSchema(applications, { modeSwarm: ServiceModeSwarmSchema.nullable(), labelsSwarm: LabelsSwarmSchema.nullable(), networkSwarm: NetworkSwarmSchema.nullable(), + previewPort: z.number().optional(), + previewEnv: z.string().optional(), + previewBuildArgs: z.string().optional(), + previewWildcard: z.string().optional(), + previewLimit: z.number().optional(), + previewHttps: z.boolean().optional(), + previewPath: z.string().optional(), + previewCertificateType: z.enum(["letsencrypt", "none"]).optional(), }); export const apiCreateApplication = createSchema.pick({ diff --git a/packages/server/src/db/schema/deployment.ts b/packages/server/src/db/schema/deployment.ts index db9838f0..f79b48ee 100644 --- a/packages/server/src/db/schema/deployment.ts +++ b/packages/server/src/db/schema/deployment.ts @@ -1,11 +1,18 @@ -import { relations } from "drizzle-orm"; -import { pgEnum, pgTable, text } from "drizzle-orm/pg-core"; +import { is, relations } from "drizzle-orm"; +import { + type AnyPgColumn, + boolean, + pgEnum, + pgTable, + text, +} 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"; import { server } from "./server"; +import { previewDeployments } from "./preview-deployments"; export const deploymentStatus = pgEnum("deploymentStatus", [ "running", @@ -32,6 +39,11 @@ export const deployments = pgTable("deployment", { serverId: text("serverId").references(() => server.serverId, { onDelete: "cascade", }), + isPreviewDeployment: boolean("isPreviewDeployment").default(false), + previewDeploymentId: text("previewDeploymentId").references( + (): AnyPgColumn => previewDeployments.previewDeploymentId, + { onDelete: "cascade" }, + ), createdAt: text("createdAt") .notNull() .$defaultFn(() => new Date().toISOString()), @@ -50,6 +62,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({ fields: [deployments.serverId], references: [server.serverId], }), + previewDeployment: one(previewDeployments, { + fields: [deployments.previewDeploymentId], + references: [previewDeployments.previewDeploymentId], + }), })); const schema = createInsertSchema(deployments, { @@ -59,6 +75,7 @@ const schema = createInsertSchema(deployments, { applicationId: z.string(), composeId: z.string(), description: z.string().optional(), + previewDeploymentId: z.string(), }); export const apiCreateDeployment = schema @@ -68,11 +85,24 @@ export const apiCreateDeployment = schema logPath: true, applicationId: true, description: true, + previewDeploymentId: true, }) .extend({ applicationId: z.string().min(1), }); +export const apiCreateDeploymentPreview = schema + .pick({ + title: true, + status: true, + logPath: true, + description: true, + previewDeploymentId: true, + }) + .extend({ + previewDeploymentId: z.string().min(1), + }); + export const apiCreateDeploymentCompose = schema .pick({ title: true, diff --git a/packages/server/src/db/schema/domain.ts b/packages/server/src/db/schema/domain.ts index 28829118..f115ce66 100644 --- a/packages/server/src/db/schema/domain.ts +++ b/packages/server/src/db/schema/domain.ts @@ -1,5 +1,6 @@ import { relations } from "drizzle-orm"; import { + type AnyPgColumn, boolean, integer, pgEnum, @@ -14,8 +15,13 @@ import { domain } from "../validations/domain"; import { applications } from "./application"; import { compose } from "./compose"; import { certificateType } from "./shared"; +import { previewDeployments } from "./preview-deployments"; -export const domainType = pgEnum("domainType", ["compose", "application"]); +export const domainType = pgEnum("domainType", [ + "compose", + "application", + "preview", +]); export const domains = pgTable("domain", { domainId: text("domainId") @@ -39,6 +45,10 @@ export const domains = pgTable("domain", { () => applications.applicationId, { onDelete: "cascade" }, ), + previewDeploymentId: text("previewDeploymentId").references( + (): AnyPgColumn => previewDeployments.previewDeploymentId, + { onDelete: "cascade" }, + ), certificateType: certificateType("certificateType").notNull().default("none"), }); @@ -51,6 +61,10 @@ export const domainsRelations = relations(domains, ({ one }) => ({ fields: [domains.composeId], references: [compose.composeId], }), + previewDeployment: one(previewDeployments, { + fields: [domains.previewDeploymentId], + references: [previewDeployments.previewDeploymentId], + }), })); const createSchema = createInsertSchema(domains, domain._def.schema.shape); @@ -65,6 +79,7 @@ export const apiCreateDomain = createSchema.pick({ composeId: true, serviceName: true, domainType: true, + previewDeploymentId: true, }); export const apiFindDomain = createSchema diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index 4a610368..f07a1870 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -29,3 +29,4 @@ export * from "./github"; export * from "./gitlab"; export * from "./server"; export * from "./utils"; +export * from "./preview-deployments"; \ No newline at end of file diff --git a/packages/server/src/db/schema/preview-deployments.ts b/packages/server/src/db/schema/preview-deployments.ts new file mode 100644 index 00000000..5d0671e8 --- /dev/null +++ b/packages/server/src/db/schema/preview-deployments.ts @@ -0,0 +1,74 @@ +import { relations } from "drizzle-orm"; +import { pgTable, text } from "drizzle-orm/pg-core"; +import { nanoid } from "nanoid"; +import { applications } from "./application"; +import { domains } from "./domain"; +import { deployments } from "./deployment"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod"; +import { generateAppName } from "./utils"; +import { applicationStatus } from "./shared"; + +export const previewDeployments = pgTable("preview_deployments", { + previewDeploymentId: text("previewDeploymentId") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + branch: text("branch").notNull(), + pullRequestId: text("pullRequestId").notNull(), + pullRequestNumber: text("pullRequestNumber").notNull(), + pullRequestURL: text("pullRequestURL").notNull(), + pullRequestTitle: text("pullRequestTitle").notNull(), + pullRequestCommentId: text("pullRequestCommentId").notNull(), + previewStatus: applicationStatus("previewStatus").notNull().default("idle"), + appName: text("appName") + .notNull() + .$defaultFn(() => generateAppName("preview")) + .unique(), + applicationId: text("applicationId") + .notNull() + .references(() => applications.applicationId, { + onDelete: "cascade", + }), + domainId: text("domainId").references(() => domains.domainId, { + onDelete: "cascade", + }), + createdAt: text("createdAt") + .notNull() + .$defaultFn(() => new Date().toISOString()), + expiresAt: text("expiresAt"), +}); + +export const previewDeploymentsRelations = relations( + previewDeployments, + ({ one, many }) => ({ + deployments: many(deployments), + domain: one(domains, { + fields: [previewDeployments.domainId], + references: [domains.domainId], + }), + application: one(applications, { + fields: [previewDeployments.applicationId], + references: [applications.applicationId], + }), + }), +); + +export const createSchema = createInsertSchema(previewDeployments, { + applicationId: z.string(), +}); + +export const apiCreatePreviewDeployment = createSchema + .pick({ + applicationId: true, + domainId: true, + branch: true, + pullRequestId: true, + pullRequestNumber: true, + pullRequestURL: true, + pullRequestTitle: true, + }) + .extend({ + applicationId: z.string().min(1), + // deploymentId: z.string().min(1), + }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 12f3b64e..f3f1e96f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ export * from "./services/mount"; export * from "./services/certificate"; export * from "./services/redirect"; export * from "./services/security"; +export * from "./services/preview-deployment"; export * from "./services/port"; export * from "./services/redis"; export * from "./services/compose"; @@ -40,6 +41,7 @@ export * from "./setup/redis-setup"; export * from "./setup/server-setup"; export * from "./setup/setup"; export * from "./setup/traefik-setup"; +export * from "./setup/server-validate"; export * from "./utils/backups/index"; export * from "./utils/backups/mariadb"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index ae8a9b76..fef1457c 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -28,6 +28,7 @@ import { getCustomGitCloneCommand, } from "@dokploy/server/utils/providers/git"; import { + authGithub, cloneGithubRepository, getGithubCloneCommand, } from "@dokploy/server/utils/providers/github"; @@ -40,8 +41,23 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { encodeBase64 } from "../utils/docker/utils"; import { getDokployUrl } from "./admin"; -import { createDeployment, updateDeploymentStatus } from "./deployment"; +import { + createDeployment, + createDeploymentPreview, + updateDeploymentStatus, +} from "./deployment"; import { validUniqueServerAppName } from "./project"; +import { + findPreviewDeploymentById, + updatePreviewDeployment, +} from "./preview-deployment"; +import { + createPreviewDeploymentComment, + getIssueComment, + issueCommentExists, + updateIssueComment, +} from "./github"; +import { type Domain, getDomainHost } from "./domain"; export type Application = typeof applications.$inferSelect; export const createApplication = async ( @@ -100,6 +116,7 @@ export const findApplicationById = async (applicationId: string) => { github: true, bitbucket: true, server: true, + previewDeployments: true, }, }); if (!application) { @@ -168,7 +185,10 @@ export const deployApplication = async ({ try { if (application.sourceType === "github") { - await cloneGithubRepository(application, deployment.logPath); + await cloneGithubRepository({ + ...application, + logPath: deployment.logPath, + }); await buildApplication(application, deployment.logPath); } else if (application.sourceType === "gitlab") { await cloneGitlabRepository(application, deployment.logPath); @@ -276,7 +296,11 @@ export const deployRemoteApplication = async ({ if (application.serverId) { let command = "set -e;"; if (application.sourceType === "github") { - command += await getGithubCloneCommand(application, deployment.logPath); + command += await getGithubCloneCommand({ + ...application, + serverId: application.serverId, + logPath: deployment.logPath, + }); } else if (application.sourceType === "gitlab") { command += await getGitlabCloneCommand(application, deployment.logPath); } else if (application.sourceType === "bitbucket") { @@ -348,6 +372,225 @@ export const deployRemoteApplication = async ({ return true; }; +export const deployPreviewApplication = async ({ + applicationId, + titleLog = "Preview Deployment", + descriptionLog = "", + previewDeploymentId, +}: { + applicationId: string; + titleLog: string; + descriptionLog: string; + previewDeploymentId: string; +}) => { + const application = await findApplicationById(applicationId); + const deployment = await createDeploymentPreview({ + title: titleLog, + description: descriptionLog, + previewDeploymentId: previewDeploymentId, + }); + + const previewDeployment = + await findPreviewDeploymentById(previewDeploymentId); + + await updatePreviewDeployment(previewDeploymentId, { + createdAt: new Date().toISOString(), + }); + + const previewDomain = getDomainHost(previewDeployment?.domain as Domain); + const issueParams = { + owner: application?.owner || "", + repository: application?.repository || "", + issue_number: previewDeployment.pullRequestNumber, + comment_id: Number.parseInt(previewDeployment.pullRequestCommentId), + githubId: application?.githubId || "", + }; + try { + const commentExists = await issueCommentExists({ + ...issueParams, + }); + if (!commentExists) { + const result = await createPreviewDeploymentComment({ + ...issueParams, + previewDomain, + appName: previewDeployment.appName, + githubId: application?.githubId || "", + previewDeploymentId, + }); + + if (!result) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pull request comment not found", + }); + } + + issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); + } + const buildingComment = getIssueComment( + application.name, + "running", + previewDomain, + ); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${buildingComment}`, + }); + application.appName = previewDeployment.appName; + application.env = application.previewEnv; + application.buildArgs = application.previewBuildArgs; + + if (application.sourceType === "github") { + await cloneGithubRepository({ + ...application, + appName: previewDeployment.appName, + branch: previewDeployment.branch, + logPath: deployment.logPath, + }); + await buildApplication(application, deployment.logPath); + } + // 4eef09efc46009187d668cf1c25f768d0bde4f91 + const successComment = getIssueComment( + application.name, + "success", + previewDomain, + ); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${successComment}`, + }); + await updateDeploymentStatus(deployment.deploymentId, "done"); + await updatePreviewDeployment(previewDeploymentId, { + previewStatus: "done", + }); + } catch (error) { + const comment = getIssueComment(application.name, "error", previewDomain); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${comment}`, + }); + await updateDeploymentStatus(deployment.deploymentId, "error"); + await updatePreviewDeployment(previewDeploymentId, { + previewStatus: "error", + }); + throw error; + } + + return true; +}; + +export const deployRemotePreviewApplication = async ({ + applicationId, + titleLog = "Preview Deployment", + descriptionLog = "", + previewDeploymentId, +}: { + applicationId: string; + titleLog: string; + descriptionLog: string; + previewDeploymentId: string; +}) => { + const application = await findApplicationById(applicationId); + const deployment = await createDeploymentPreview({ + title: titleLog, + description: descriptionLog, + previewDeploymentId: previewDeploymentId, + }); + + const previewDeployment = + await findPreviewDeploymentById(previewDeploymentId); + + await updatePreviewDeployment(previewDeploymentId, { + createdAt: new Date().toISOString(), + }); + + const previewDomain = getDomainHost(previewDeployment?.domain as Domain); + const issueParams = { + owner: application?.owner || "", + repository: application?.repository || "", + issue_number: previewDeployment.pullRequestNumber, + comment_id: Number.parseInt(previewDeployment.pullRequestCommentId), + githubId: application?.githubId || "", + }; + try { + const commentExists = await issueCommentExists({ + ...issueParams, + }); + if (!commentExists) { + const result = await createPreviewDeploymentComment({ + ...issueParams, + previewDomain, + appName: previewDeployment.appName, + githubId: application?.githubId || "", + previewDeploymentId, + }); + + if (!result) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pull request comment not found", + }); + } + + issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId); + } + const buildingComment = getIssueComment( + application.name, + "running", + previewDomain, + ); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${buildingComment}`, + }); + application.appName = previewDeployment.appName; + application.env = application.previewEnv; + application.buildArgs = application.previewBuildArgs; + + if (application.serverId) { + let command = "set -e;"; + if (application.sourceType === "github") { + command += await getGithubCloneCommand({ + ...application, + serverId: application.serverId, + logPath: deployment.logPath, + }); + } + + command += getBuildCommand(application, deployment.logPath); + await execAsyncRemote(application.serverId, command); + await mechanizeDockerContainer(application); + } + + const successComment = getIssueComment( + application.name, + "success", + previewDomain, + ); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${successComment}`, + }); + await updateDeploymentStatus(deployment.deploymentId, "done"); + await updatePreviewDeployment(previewDeploymentId, { + previewStatus: "done", + }); + } catch (error) { + const comment = getIssueComment(application.name, "error", previewDomain); + await updateIssueComment({ + ...issueParams, + body: `### Dokploy Preview Deployment\n\n${comment}`, + }); + await updateDeploymentStatus(deployment.deploymentId, "error"); + await updatePreviewDeployment(previewDeploymentId, { + previewStatus: "error", + }); + throw error; + } + + return true; +}; + export const rebuildRemoteApplication = async ({ applicationId, titleLog = "Rebuild deployment", diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 604c2150..5ae0d774 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -214,7 +214,11 @@ export const deployCompose = async ({ try { if (compose.sourceType === "github") { - await cloneGithubRepository(compose, deployment.logPath, true); + await cloneGithubRepository({ + ...compose, + logPath: deployment.logPath, + type: "compose", + }); } else if (compose.sourceType === "gitlab") { await cloneGitlabRepository(compose, deployment.logPath, true); } else if (compose.sourceType === "bitbucket") { @@ -314,11 +318,12 @@ export const deployRemoteCompose = async ({ let command = "set -e;"; if (compose.sourceType === "github") { - command += await getGithubCloneCommand( - compose, - deployment.logPath, - true, - ); + command += await getGithubCloneCommand({ + ...compose, + logPath: deployment.logPath, + type: "compose", + serverId: compose.serverId, + }); } else if (compose.sourceType === "gitlab") { command += await getGitlabCloneCommand( compose, diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 63f3cf23..b18b132d 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -5,13 +5,14 @@ import { db } from "@dokploy/server/db"; import { type apiCreateDeployment, type apiCreateDeploymentCompose, + type apiCreateDeploymentPreview, type apiCreateDeploymentServer, deployments, } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, isNull } from "drizzle-orm"; import { type Application, findApplicationById, @@ -21,6 +22,11 @@ import { type Compose, findComposeById, updateCompose } from "./compose"; import { type Server, findServerById } from "./server"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { + findPreviewDeploymentById, + type PreviewDeployment, + updatePreviewDeployment, +} from "./preview-deployment"; export type Deployment = typeof deployments.$inferSelect; @@ -101,6 +107,74 @@ export const createDeployment = async ( } }; +export const createDeploymentPreview = async ( + deployment: Omit< + typeof apiCreateDeploymentPreview._type, + "deploymentId" | "createdAt" | "status" | "logPath" + >, +) => { + const previewDeployment = await findPreviewDeploymentById( + deployment.previewDeploymentId, + ); + try { + await removeLastTenPreviewDeploymenById( + deployment.previewDeploymentId, + previewDeployment?.application?.serverId, + ); + + const appName = `${previewDeployment.appName}`; + const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId); + const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); + const fileName = `${appName}-${formattedDateTime}.log`; + const logFilePath = path.join(LOGS_PATH, appName, fileName); + + if (previewDeployment?.application?.serverId) { + const server = await findServerById( + previewDeployment?.application?.serverId, + ); + + const command = ` + mkdir -p ${LOGS_PATH}/${appName}; + echo "Initializing deployment" >> ${logFilePath}; + `; + + await execAsyncRemote(server.serverId, command); + } else { + await fsPromises.mkdir(path.join(LOGS_PATH, appName), { + recursive: true, + }); + await fsPromises.writeFile(logFilePath, "Initializing deployment"); + } + + const deploymentCreate = await db + .insert(deployments) + .values({ + title: deployment.title || "Deployment", + status: "running", + logPath: logFilePath, + description: deployment.description || "", + previewDeploymentId: deployment.previewDeploymentId, + }) + .returning(); + if (deploymentCreate.length === 0 || !deploymentCreate[0]) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the deployment", + }); + } + return deploymentCreate[0]; + } catch (error) { + await updatePreviewDeployment(deployment.previewDeploymentId, { + previewStatus: "error", + }); + console.log(error); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the deployment", + }); + } +}; + export const createDeploymentCompose = async ( deployment: Omit< typeof apiCreateDeploymentCompose._type, @@ -257,6 +331,41 @@ const removeLastTenComposeDeployments = async ( } }; +export const removeLastTenPreviewDeploymenById = async ( + previewDeploymentId: string, + serverId: string | null, +) => { + const deploymentList = await db.query.deployments.findMany({ + where: eq(deployments.previewDeploymentId, previewDeploymentId), + orderBy: desc(deployments.createdAt), + }); + + if (deploymentList.length > 10) { + const deploymentsToDelete = deploymentList.slice(10); + if (serverId) { + let command = ""; + for (const oldDeployment of deploymentsToDelete) { + const logPath = path.join(oldDeployment.logPath); + + command += ` + rm -rf ${logPath}; + `; + await removeDeployment(oldDeployment.deploymentId); + } + + await execAsyncRemote(serverId, command); + } else { + for (const oldDeployment of deploymentsToDelete) { + const logPath = path.join(oldDeployment.logPath); + if (existsSync(logPath)) { + await fsPromises.unlink(logPath); + } + await removeDeployment(oldDeployment.deploymentId); + } + } + } +}; + export const removeDeployments = async (application: Application) => { const { appName, applicationId } = application; const { LOGS_PATH } = paths(!!application.serverId); @@ -269,6 +378,30 @@ export const removeDeployments = async (application: Application) => { await removeDeploymentsByApplicationId(applicationId); }; +export const removeDeploymentsByPreviewDeploymentId = async ( + previewDeployment: PreviewDeployment, + serverId: string | null, +) => { + const { appName } = previewDeployment; + const { LOGS_PATH } = paths(!!serverId); + const logsPath = path.join(LOGS_PATH, appName); + if (serverId) { + await execAsyncRemote(serverId, `rm -rf ${logsPath}`); + } else { + await removeDirectoryIfExistsContent(logsPath); + } + + await db + .delete(deployments) + .where( + eq( + deployments.previewDeploymentId, + previewDeployment.previewDeploymentId, + ), + ) + .returning(); +}; + export const removeDeploymentsByComposeId = async (compose: Compose) => { const { appName } = compose; const { LOGS_PATH } = paths(!!compose.serverId); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index d5a40fe0..6ac61354 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -110,7 +110,7 @@ export const getContainersByAppNameMatch = async ( const command = appType === "docker-compose" ? `${cmd} --filter='label=com.docker.compose.project=${appName}'` - : `${cmd} | grep ${appName}`; + : `${cmd} | grep '^.*Name: ${appName}'`; if (serverId) { const { stdout, stderr } = await execAsyncRemote(serverId, command); diff --git a/packages/server/src/services/domain.ts b/packages/server/src/services/domain.ts index 28dd3ba2..b99c4869 100644 --- a/packages/server/src/services/domain.ts +++ b/packages/server/src/services/domain.ts @@ -134,3 +134,7 @@ export const removeDomainById = async (domainId: string) => { return result[0]; }; + +export const getDomainHost = (domain: Domain) => { + return `${domain.https ? "https" : "http"}://${domain.host}`; +}; diff --git a/packages/server/src/services/github.ts b/packages/server/src/services/github.ts index a7317bc7..d60c8353 100644 --- a/packages/server/src/services/github.ts +++ b/packages/server/src/services/github.ts @@ -6,6 +6,8 @@ import { } from "@dokploy/server/db/schema"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import { authGithub } from "../utils/providers/github"; +import { updatePreviewDeployment } from "./preview-deployment"; export type Github = typeof github.$inferSelect; export const createGithub = async ( @@ -72,3 +74,119 @@ export const updateGithub = async ( .returning() .then((response) => response[0]); }; + +export const getIssueComment = ( + appName: string, + status: "success" | "error" | "running" | "initializing", + previewDomain: string, +) => { + let statusMessage = ""; + if (status === "success") { + statusMessage = "✅ Done"; + } else if (status === "error") { + statusMessage = "❌ Failed"; + } else if (status === "initializing") { + statusMessage = "🔄 Building"; + } else { + statusMessage = "🔄 Building"; + } + const finished = ` +| Name | Status | Preview | Updated (UTC) | +|------------|--------------|-------------------------------------|-----------------------| +| ${appName} | ${statusMessage} | [Preview URL](${previewDomain}) | ${new Date().toISOString()} | +`; + + return finished; +}; +interface CommentExists { + owner: string; + repository: string; + comment_id: number; + githubId: string; +} +export const issueCommentExists = async ({ + owner, + repository, + comment_id, + githubId, +}: CommentExists) => { + const github = await findGithubById(githubId); + const octokit = authGithub(github); + try { + await octokit.rest.issues.getComment({ + owner: owner || "", + repo: repository || "", + comment_id: comment_id, + }); + return true; + } catch (error) { + return false; + } +}; +interface Comment { + owner: string; + repository: string; + issue_number: string; + body: string; + comment_id: number; + githubId: string; +} +export const updateIssueComment = async ({ + owner, + repository, + issue_number, + body, + comment_id, + githubId, +}: Comment) => { + const github = await findGithubById(githubId); + const octokit = authGithub(github); + + await octokit.rest.issues.updateComment({ + owner: owner || "", + repo: repository || "", + issue_number: issue_number, + body, + comment_id: comment_id, + }); +}; + +interface CommentCreate { + appName: string; + owner: string; + repository: string; + issue_number: string; + previewDomain: string; + githubId: string; + previewDeploymentId: string; +} + +export const createPreviewDeploymentComment = async ({ + owner, + repository, + issue_number, + previewDomain, + appName, + githubId, + previewDeploymentId, +}: CommentCreate) => { + const github = await findGithubById(githubId); + const octokit = authGithub(github); + + const runningComment = getIssueComment( + appName, + "initializing", + previewDomain, + ); + + const issue = await octokit.rest.issues.createComment({ + owner: owner || "", + repo: repository || "", + issue_number: Number.parseInt(issue_number), + body: `### Dokploy Preview Deployment\n\n${runningComment}`, + }); + + return await updatePreviewDeployment(previewDeploymentId, { + pullRequestCommentId: `${issue.data.id}`, + }).then((response) => response[0]); +}; diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts new file mode 100644 index 00000000..e52f4553 --- /dev/null +++ b/packages/server/src/services/preview-deployment.ts @@ -0,0 +1,283 @@ +import { db } from "@dokploy/server/db"; +import { + type apiCreatePreviewDeployment, + deployments, + previewDeployments, +} from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { and, desc, eq } from "drizzle-orm"; +import { slugify } from "../setup/server-setup"; +import { findApplicationById } from "./application"; +import { createDomain } from "./domain"; +import { generatePassword, generateRandomDomain } from "../templates/utils"; +import { manageDomain } from "../utils/traefik/domain"; +import { + removeDeployments, + removeDeploymentsByPreviewDeploymentId, +} from "./deployment"; +import { removeDirectoryCode } from "../utils/filesystem/directory"; +import { removeTraefikConfig } from "../utils/traefik/application"; +import { removeService } from "../utils/docker/utils"; +import { authGithub } from "../utils/providers/github"; +import { getIssueComment, type Github } from "./github"; +import { findAdminById } from "./admin"; + +export type PreviewDeployment = typeof previewDeployments.$inferSelect; + +export const findPreviewDeploymentById = async ( + previewDeploymentId: string, +) => { + const application = await db.query.previewDeployments.findFirst({ + where: eq(previewDeployments.previewDeploymentId, previewDeploymentId), + with: { + domain: true, + application: { + with: { + server: true, + project: true, + }, + }, + }, + }); + if (!application) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Preview Deployment not found", + }); + } + return application; +}; + +export const findApplicationByPreview = async (applicationId: string) => { + const application = await db.query.applications.findFirst({ + with: { + previewDeployments: { + where: eq(previewDeployments.applicationId, applicationId), + }, + project: true, + domains: true, + deployments: true, + mounts: true, + redirects: true, + security: true, + ports: true, + registry: true, + gitlab: true, + github: true, + bitbucket: true, + server: true, + }, + }); + + if (!application) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Applicationnot found", + }); + } + return application; +}; + +export const removePreviewDeployment = async (previewDeploymentId: string) => { + try { + const application = await findApplicationByPreview(previewDeploymentId); + const previewDeployment = + await findPreviewDeploymentById(previewDeploymentId); + + const deployment = await db + .delete(previewDeployments) + .where(eq(previewDeployments.previewDeploymentId, previewDeploymentId)) + .returning(); + + application.appName = previewDeployment.appName; + const cleanupOperations = [ + async () => + await removeDeploymentsByPreviewDeploymentId( + previewDeployment, + application.serverId, + ), + async () => + await removeDirectoryCode(application.appName, application.serverId), + async () => + await removeTraefikConfig(application.appName, application.serverId), + async () => + await removeService(application?.appName, application.serverId), + ]; + for (const operation of cleanupOperations) { + try { + await operation(); + } catch (error) {} + } + return deployment[0]; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to delete this preview deployment", + }); + } +}; +// testing-tesoitnmg-ddq0ul-preview-ihl44o +export const updatePreviewDeployment = async ( + previewDeploymentId: string, + previewDeploymentData: Partial, +) => { + const application = await db + .update(previewDeployments) + .set({ + ...previewDeploymentData, + }) + .where(eq(previewDeployments.previewDeploymentId, previewDeploymentId)) + .returning(); + + return application; +}; + +export const findPreviewDeploymentsByApplicationId = async ( + applicationId: string, +) => { + const deploymentsList = await db.query.previewDeployments.findMany({ + where: eq(previewDeployments.applicationId, applicationId), + orderBy: desc(previewDeployments.createdAt), + with: { + deployments: { + orderBy: desc(deployments.createdAt), + }, + domain: true, + }, + }); + return deploymentsList; +}; + +export const createPreviewDeployment = async ( + schema: typeof apiCreatePreviewDeployment._type, +) => { + const application = await findApplicationById(schema.applicationId); + const appName = `preview-${application.appName}-${generatePassword(6)}`; + + const generateDomain = await generateWildcardDomain( + application.previewWildcard || "*.traefik.me", + appName, + application.server?.ipAddress || "", + application.project.adminId, + ); + + const octokit = authGithub(application?.github as Github); + + const runningComment = getIssueComment( + application.name, + "initializing", + generateDomain, + ); + + const issue = await octokit.rest.issues.createComment({ + owner: application?.owner || "", + repo: application?.repository || "", + issue_number: Number.parseInt(schema.pullRequestNumber), + body: `### Dokploy Preview Deployment\n\n${runningComment}`, + }); + + const previewDeployment = await db + .insert(previewDeployments) + .values({ + ...schema, + appName: appName, + pullRequestCommentId: `${issue.data.id}`, + }) + .returning() + .then((value) => value[0]); + + if (!previewDeployment) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Error to create the preview deployment", + }); + } + + const newDomain = await createDomain({ + host: generateDomain, + path: application.previewPath, + port: application.previewPort, + https: application.previewHttps, + certificateType: application.previewCertificateType, + domainType: "preview", + previewDeploymentId: previewDeployment.previewDeploymentId, + }); + + application.appName = appName; + + await manageDomain(application, newDomain); + + await db + .update(previewDeployments) + .set({ + domainId: newDomain.domainId, + }) + .where( + eq( + previewDeployments.previewDeploymentId, + previewDeployment.previewDeploymentId, + ), + ); + + return previewDeployment; +}; + +export const findPreviewDeploymentsByPullRequestId = async ( + pullRequestId: string, +) => { + const previewDeploymentResult = await db.query.previewDeployments.findMany({ + where: eq(previewDeployments.pullRequestId, pullRequestId), + }); + + return previewDeploymentResult; +}; + +export const findPreviewDeploymentByApplicationId = async ( + applicationId: string, + pullRequestId: string, +) => { + const previewDeploymentResult = await db.query.previewDeployments.findFirst({ + where: and( + eq(previewDeployments.applicationId, applicationId), + eq(previewDeployments.pullRequestId, pullRequestId), + ), + }); + + return previewDeploymentResult; +}; + +const generateWildcardDomain = async ( + baseDomain: string, + appName: string, + serverIp: string, + adminId: string, +): Promise => { + if (!baseDomain.startsWith("*.")) { + throw new Error('The base domain must start with "*."'); + } + const hash = `${appName}`; + if (baseDomain.includes("traefik.me")) { + let ip = ""; + + if (process.env.NODE_ENV === "development") { + ip = "127.0.0.1"; + } + + if (serverIp) { + ip = serverIp; + } + + if (!ip) { + const admin = await findAdminById(adminId); + ip = admin?.serverIp || ""; + } + + const slugIp = ip.replaceAll(".", "-"); + return baseDomain.replace( + "*", + `${hash}${slugIp === "" ? "" : `-${slugIp}`}`, + ); + } + + return baseDomain.replace("*", hash); +}; diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index f2abd3c5..55ebdc6b 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -74,25 +74,106 @@ const installRequirements = async (serverId: string, logPath: string) => { client .once("ready", () => { const bashCommand = ` + set -e; + # Thanks to coolify <3 + + DOCKER_VERSION=27.0.3 + OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') + CURRENT_USER=$USER + + echo "Installing requirements for: OS: $OS_TYPE" + if [ $EUID != 0 ]; then + echo "Please run this script as root or with sudo ❌" + exit + fi - ${validatePorts()} + # Check if the OS is manjaro, if so, change it to arch + if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then + OS_TYPE="arch" + fi + + # Check if the OS is Asahi Linux, if so, change it to fedora + if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then + OS_TYPE="fedora" + fi + + # Check if the OS is popOS, if so, change it to ubuntu + if [ "$OS_TYPE" = "pop" ]; then + OS_TYPE="ubuntu" + fi + + # Check if the OS is linuxmint, if so, change it to ubuntu + if [ "$OS_TYPE" = "linuxmint" ]; then + OS_TYPE="ubuntu" + fi + + #Check if the OS is zorin, if so, change it to ubuntu + if [ "$OS_TYPE" = "zorin" ]; then + OS_TYPE="ubuntu" + fi + + if [ "$OS_TYPE" = "arch" ] || [ "$OS_TYPE" = "archarm" ]; then + OS_VERSION="rolling" + else + OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"') + fi + + case "$OS_TYPE" in + arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine) ;; + *) + echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now." + exit + ;; + esac + + echo -e "---------------------------------------------" + echo "| Operating System | $OS_TYPE $OS_VERSION" + echo "| Docker | $DOCKER_VERSION" + echo -e "---------------------------------------------\n" + echo -e "1. Installing required packages (curl, wget, git, jq, openssl). " command_exists() { command -v "$@" > /dev/null 2>&1 } + + ${installUtilities()} + + echo -e "2. Validating ports. " + ${validatePorts()} + + + + echo -e "3. Installing RClone. " ${installRClone()} + + echo -e "4. Installing Docker. " ${installDocker()} + + echo -e "5. Setting up Docker Swarm" ${setupSwarm()} + + echo -e "6. Setting up Network" ${setupNetwork()} + + echo -e "7. Setting up Directories" ${setupMainDirectory()} ${setupDirectories()} + + echo -e "8. Setting up Traefik" ${createTraefikConfig()} + + echo -e "9. Setting up Middlewares" ${createDefaultMiddlewares()} + + echo -e "10. Setting up Traefik Instance" ${createTraefikInstance()} + + echo -e "11. Installing Nixpacks" ${installNixpacks()} + + echo -e "12. Installing Buildpacks" ${installBuildpacks()} `; - client.exec(bashCommand, (err, stream) => { if (err) { writeStream.write(err); @@ -204,17 +285,12 @@ const setupNetwork = () => ` echo "Network dokploy-network already exists ✅" else # Create the dokploy-network if it doesn't exist - docker network create --driver overlay --attachable dokploy-network - echo "Network created ✅" - fi -`; - -const installDocker = () => ` - if command_exists docker; then - echo "Docker already installed ✅" - else - echo "Installing Docker ✅" - curl -sSL https://get.docker.com | sh -s -- --version 27.2.0 + if docker network create --driver overlay --attachable dokploy-network; then + echo "Network created ✅" + else + echo "Failed to create dokploy-network ❌" >&2 + exit 1 + fi fi `; @@ -230,6 +306,155 @@ const validatePorts = () => ` fi `; +const installUtilities = () => ` + + case "$OS_TYPE" in + arch) + pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true + ;; + alpine) + sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories + apk update >/dev/null + apk add curl wget git jq openssl >/dev/null + ;; + ubuntu | debian | raspbian) + apt-get update -y >/dev/null + apt-get install -y curl wget git jq openssl >/dev/null + ;; + centos | fedora | rhel | ol | rocky | almalinux | amzn) + if [ "$OS_TYPE" = "amzn" ]; then + dnf install -y wget git jq openssl >/dev/null + else + if ! command -v dnf >/dev/null; then + yum install -y dnf >/dev/null + fi + if ! command -v curl >/dev/null; then + dnf install -y curl >/dev/null + fi + dnf install -y wget git jq openssl unzip >/dev/null + fi + ;; + sles | opensuse-leap | opensuse-tumbleweed) + zypper refresh >/dev/null + zypper install -y curl wget git jq openssl >/dev/null + ;; + *) + echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now." + exit + ;; + esac +`; + +const installDocker = () => ` + +# Detect if docker is installed via snap +if [ -x "$(command -v snap)" ]; then + SNAP_DOCKER_INSTALLED=$(snap list docker >/dev/null 2>&1 && echo "true" || echo "false") + if [ "$SNAP_DOCKER_INSTALLED" = "true" ]; then + echo " - Docker is installed via snap." + echo " Please note that Dokploy does not support Docker installed via snap." + echo " Please remove Docker with snap (snap remove docker) and reexecute this script." + exit 1 + fi +fi + +echo -e "3. Check Docker Installation. " +if ! [ -x "$(command -v docker)" ]; then + echo " - Docker is not installed. Installing Docker. It may take a while." + case "$OS_TYPE" in + "almalinux") + dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1 + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + ;; + "alpine") + apk add docker docker-cli-compose >/dev/null 2>&1 + rc-update add docker default >/dev/null 2>&1 + service docker start >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with apk. Try to install it manually." + echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information." + exit 1 + fi + ;; + "arch") + pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1 + systemctl enable docker.service >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with pacman. Try to install it manually." + echo " Please visit https://wiki.archlinux.org/title/docker for more information." + exit 1 + fi + ;; + "amzn") + dnf install docker -y >/dev/null 2>&1 + DOCKER_CONFIG=/usr/local/lib/docker + mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1 + curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1 + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Failed to install Docker with dnf. Try to install it manually." + echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information." + exit 1 + fi + ;; + "fedora") + if [ -x "$(command -v dnf5)" ]; then + # dnf5 is available + dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1 + else + # dnf5 is not available, use dnf + dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1 + fi + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + ;; + *) + if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then + echo "Docker automated installation is not supported on Ubuntu 24.10 (non-LTS release)." + echo "Please install Docker manually." + exit 1 + fi + curl -s https://releases.rancher.com/install-docker/$DOCKER_VERSION.sh | sh 2>&1 + if ! [ -x "$(command -v docker)" ]; then + curl -s https://get.docker.com | sh -s -- --version $DOCKER_VERSION 2>&1 + if ! [ -x "$(command -v docker)" ]; then + echo " - Docker installation failed." + echo " Maybe your OS is not supported?" + echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue." + exit 1 + fi + fi + if [ "$OS_TYPE" = "rocky" ]; then + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + fi + + if [ "$OS_TYPE" = "centos" ]; then + systemctl start docker >/dev/null 2>&1 + systemctl enable docker >/dev/null 2>&1 + fi + + + esac + echo " - Docker installed successfully." +else + echo " - Docker is installed." +fi +`; + const createTraefikConfig = () => { const config = getDefaultServerTraefikConfig(); @@ -260,7 +485,12 @@ const createDefaultMiddlewares = () => { }; export const installRClone = () => ` -curl https://rclone.org/install.sh | sudo bash + if command_exists rclone; then + echo "RClone already installed ✅" + else + curl https://rclone.org/install.sh | sudo bash + echo "RClone installed successfully ✅" + fi `; export const createTraefikInstance = () => { @@ -292,7 +522,8 @@ const installNixpacks = () => ` if command_exists nixpacks; then echo "Nixpacks already installed ✅" else - VERSION=1.28.1 bash -c "$(curl -fsSL https://nixpacks.com/install.sh)" + export NIXPACKS_VERSION=1.29.1 + bash -c "$(curl -fsSL https://nixpacks.com/install.sh)" echo "Nixpacks version 1.28.1 installed ✅" fi `; diff --git a/packages/server/src/setup/server-validate.ts b/packages/server/src/setup/server-validate.ts new file mode 100644 index 00000000..0729feed --- /dev/null +++ b/packages/server/src/setup/server-validate.ts @@ -0,0 +1,144 @@ +import { Client } from "ssh2"; +import { findServerById } from "../services/server"; + +export const validateDocker = () => ` + if command_exists docker; then + echo "$(docker --version | awk '{print $3}' | sed 's/,//') true" + else + echo "0.0.0 false" + fi +`; + +export const validateRClone = () => ` + if command_exists rclone; then + echo "$(rclone --version | head -n 1 | awk '{print $2}') true" + else + echo "0.0.0 false" + fi +`; + +export const validateSwarm = () => ` + if docker info --format '{{.Swarm.LocalNodeState}}' | grep -q 'active'; then + echo true + else + echo false + fi +`; + +export const validateNixpacks = () => ` + if command_exists nixpacks; then + echo "$(nixpacks --version | awk '{print $2}') true" + else + echo "0.0.0 false" + fi +`; + +export const validateBuildpacks = () => ` + if command_exists pack; then + echo "$(pack --version | awk '{print $1}') true" + else + echo "0.0.0 false" + fi +`; + +export const validateMainDirectory = () => ` + if [ -d "/etc/dokploy" ]; then + echo true + else + echo false + fi +`; + +export const validateDokployNetwork = () => ` + if docker network ls | grep -q 'dokploy-network'; then + echo true + else + echo false + fi +`; + +export const serverValidate = async (serverId: string) => { + const client = new Client(); + const server = await findServerById(serverId); + if (!server.sshKeyId) { + throw new Error("No SSH Key found"); + } + + return new Promise((resolve, reject) => { + client + .once("ready", () => { + const bashCommand = ` + command_exists() { + command -v "$@" > /dev/null 2>&1 + } + + dockerVersionEnabled=$(${validateDocker()}) + rcloneVersionEnabled=$(${validateRClone()}) + nixpacksVersionEnabled=$(${validateNixpacks()}) + buildpacksVersionEnabled=$(${validateBuildpacks()}) + + dockerVersion=$(echo $dockerVersionEnabled | awk '{print $1}') + dockerEnabled=$(echo $dockerVersionEnabled | awk '{print $2}') + + rcloneVersion=$(echo $rcloneVersionEnabled | awk '{print $1}') + rcloneEnabled=$(echo $rcloneVersionEnabled | awk '{print $2}') + + nixpacksVersion=$(echo $nixpacksVersionEnabled | awk '{print $1}') + nixpacksEnabled=$(echo $nixpacksVersionEnabled | awk '{print $2}') + + buildpacksVersion=$(echo $buildpacksVersionEnabled | awk '{print $1}') + buildpacksEnabled=$(echo $buildpacksVersionEnabled | awk '{print $2}') + + isDokployNetworkInstalled=$(${validateDokployNetwork()}) + isSwarmInstalled=$(${validateSwarm()}) + isMainDirectoryInstalled=$(${validateMainDirectory()}) + + echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled}" + `; + client.exec(bashCommand, (err, stream) => { + if (err) { + reject(err); + return; + } + let output = ""; + stream + .on("close", () => { + client.end(); + try { + const result = JSON.parse(output.trim()); + resolve(result); + } catch (parseError) { + reject( + new Error( + `Failed to parse output: ${parseError instanceof Error ? parseError.message : parseError}`, + ), + ); + } + }) + .on("data", (data: string) => { + output += data; + }) + .stderr.on("data", (data) => {}); + }); + }) + .on("error", (err) => { + client.end(); + if (err.level === "client-authentication") { + reject( + new Error( + `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`, + ), + ); + } else { + reject(new Error(`SSH connection error: ${err.message}`)); + } + }) + .connect({ + host: server.ipAddress, + port: server.port, + username: server.username, + privateKey: server.sshKey?.privateKey, + timeout: 99999, + }); + }); +}; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 702121d2..1cdc9787 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -17,6 +17,7 @@ import { buildHeroku, getHerokuCommand } from "./heroku"; import { buildNixpacks, getNixpacksCommand } from "./nixpacks"; import { buildPaketo, getPaketoCommand } from "./paketo"; import { buildStatic, getStaticCommand } from "./static"; +import { nanoid } from "nanoid"; // NIXPACKS codeDirectory = where is the path of the code directory // HEROKU codeDirectory = where is the path of the code directory @@ -33,6 +34,7 @@ export type ApplicationNested = InferResultType< project: true; } >; + export const buildApplication = async ( application: ApplicationNested, logPath: string, diff --git a/packages/server/src/utils/builders/nixpacks.ts b/packages/server/src/utils/builders/nixpacks.ts index 7c10e4c0..56560e4e 100644 --- a/packages/server/src/utils/builders/nixpacks.ts +++ b/packages/server/src/utils/builders/nixpacks.ts @@ -14,7 +14,7 @@ export const buildNixpacks = async ( application: ApplicationNested, writeStream: WriteStream, ) => { - const { env, appName, publishDirectory, serverId } = application; + const { env, appName, publishDirectory } = application; const buildAppDirectory = getBuildAppDirectory(application); const buildContainerId = `${appName}-${nanoid(10)}`; diff --git a/packages/server/src/utils/gpu-setup.ts b/packages/server/src/utils/gpu-setup.ts index ce60adf1..0b49dc6c 100644 --- a/packages/server/src/utils/gpu-setup.ts +++ b/packages/server/src/utils/gpu-setup.ts @@ -303,7 +303,7 @@ const setupLocalServer = async (daemonConfig: any) => { await fs.writeFile(configFile, JSON.stringify(daemonConfig, null, 2)); const setupCommands = [ - `pkexec sh -c ' + `sudo sh -c ' cp ${configFile} /etc/docker/daemon.json && mkdir -p /etc/nvidia-container-runtime && sed -i "/swarm-resource/d" /etc/nvidia-container-runtime/config.toml && @@ -314,7 +314,14 @@ const setupLocalServer = async (daemonConfig: any) => { `rm ${configFile}`, ].join(" && "); - await execAsync(setupCommands); + try { + await execAsync(setupCommands); + } catch (error) { + console.error("Setup failed:", error); + throw new Error( + "Failed to configure GPU support. Please ensure you have sudo privileges and try again.", + ); + } }; const addGpuLabel = async (nodeId: string, serverId?: string) => { diff --git a/packages/server/src/utils/providers/github.ts b/packages/server/src/utils/providers/github.ts index a63a822d..c366eeba 100644 --- a/packages/server/src/utils/providers/github.ts +++ b/packages/server/src/utils/providers/github.ts @@ -74,11 +74,22 @@ export type ApplicationWithGithub = InferResultType< >; export type ComposeWithGithub = InferResultType<"compose", { github: true }>; -export const cloneGithubRepository = async ( - entity: ApplicationWithGithub | ComposeWithGithub, - logPath: string, - isCompose = false, -) => { + +interface CloneGithubRepository { + appName: string; + owner: string | null; + branch: string | null; + githubId: string | null; + repository: string | null; + logPath: string; + type?: "application" | "compose"; +} +export const cloneGithubRepository = async ({ + logPath, + type = "application", + ...entity +}: CloneGithubRepository) => { + const isCompose = type === "compose"; const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(); const writeStream = createWriteStream(logPath, { flags: "a" }); const { appName, repository, owner, branch, githubId } = entity; @@ -145,13 +156,13 @@ export const cloneGithubRepository = async ( } }; -export const getGithubCloneCommand = async ( - entity: ApplicationWithGithub | ComposeWithGithub, - logPath: string, - isCompose = false, -) => { +export const getGithubCloneCommand = async ({ + logPath, + type = "application", + ...entity +}: CloneGithubRepository & { serverId: string }) => { const { appName, repository, owner, branch, githubId, serverId } = entity; - + const isCompose = type === "compose"; if (!serverId) { throw new TRPCError({ code: "NOT_FOUND",