diff --git a/apps/dokploy/__test__/preview-deployment/template.test.ts b/apps/dokploy/__test__/preview-deployment/template.test.ts new file mode 100644 index 0000000000..85388b4ce5 --- /dev/null +++ b/apps/dokploy/__test__/preview-deployment/template.test.ts @@ -0,0 +1,80 @@ +import { interpolateSubdomainTemplate } from "../../../../packages/server/src/services/preview-deployment"; +import { expect, test } from "vitest"; + +const baseVars = { + appName: "my-app", + prNumber: "123", + branchName: "feature/login-page", + uniqueId: "abc123", +}; + +test("replaces ${prNumber} variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}.previews.example.com", + baseVars, + ); + expect(result).toBe("123.previews.example.com"); +}); + +test("replaces ${branchName} with slugified value", () => { + const result = interpolateSubdomainTemplate( + "${branchName}.previews.example.com", + baseVars, + ); + expect(result).toBe("feature-login-page.previews.example.com"); +}); + +test("replaces ${appName} variable", () => { + const result = interpolateSubdomainTemplate( + "${appName}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("my-app-123.example.com"); +}); + +test("replaces ${uniqueId} variable", () => { + const result = interpolateSubdomainTemplate( + "preview-${uniqueId}.example.com", + baseVars, + ); + expect(result).toBe("preview-abc123.example.com"); +}); + +test("replaces all variables in a complex template", () => { + const result = interpolateSubdomainTemplate( + "${appName}-pr${prNumber}-${branchName}.example.com", + baseVars, + ); + expect(result).toBe("my-app-pr123-feature-login-page.example.com"); +}); + +test("leaves template unchanged when no variables present", () => { + const result = interpolateSubdomainTemplate("*.traefik.me", baseVars); + expect(result).toBe("*.traefik.me"); +}); + +test("slugifies branch names with special characters", () => { + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: "feat/SOME_THING@v2.0", + }); + expect(result).toBe("feat-some-thing-v2-0.example.com"); +}); + +test("truncates slugified branch to 63 chars for DNS compliance", () => { + const longBranch = "a".repeat(100); + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: longBranch, + }); + const subdomain = result.split(".")[0]!; + expect(subdomain.length).toBeLessThanOrEqual(63); +}); + +test("handles multiple occurrences of the same variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("123-123.example.com"); +}); 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 index 13b9a16032..4efa1fee10 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -1,6 +1,6 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { HelpCircle, Plus, Settings2, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -102,7 +102,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { const previewHttps = form.watch("previewHttps"); const wildcardDomain = form.watch("wildcardDomain"); - const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false; + const isSslipDomain = wildcardDomain?.includes("sslip.io") || false; + + const templatePreview = useMemo(() => { + const template = wildcardDomain || "*.sslip.io"; + const appName = data?.appName || "my-app"; + const exampleVars: Record = { + appName, + prNumber: "42", + branchName: "feature-login", + uniqueId: "a1b2c3", + }; + let result = template.replace( + /\$\{(appName|prNumber|branchName|uniqueId)\}/g, + (_match: string, key: string) => exampleVars[key] ?? "", + ); + const hasUniqueVar = + template.includes("${prNumber}") || + template.includes("${branchName}") || + template.includes("${uniqueId}"); + if (!hasUniqueVar) { + result = result.replace("*", `${appName}-a1b2c3`); + } else { + result = result.replace("*", appName); + } + return result; + }, [wildcardDomain, data?.appName]); useEffect(() => { setIsEnabled(data?.isPreviewDeploymentsActive || false); @@ -171,7 +196,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
- {isTraefikMeDomain && ( + {isSslipDomain && ( Note: sslip.io is a public HTTP service and does not support SSL/HTTPS. HTTPS and certificate options will @@ -189,11 +214,33 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { control={form.control} name="wildcardDomain" render={({ field }) => ( - - Wildcard Domain + + Preview Domain Template - + + + + Use *. for + auto-generated subdomains, or template variables for + custom patterns. If the template includes{" "} + {"${prNumber}"} or{" "} + {"${branchName}"}, + no random suffix is appended. + + + {"${appName}"} + {"${prNumber}"} + {"${branchName}"} + {"${uniqueId}"} + + + Example: {templatePreview} + + )} diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 20f64259bc..cd5c5619ef 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -126,21 +126,73 @@ export const findPreviewDeploymentsByApplicationId = async ( return deploymentsList; }; +const slugify = (value: string): string => { + return value + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 63); +}; + +export const interpolateSubdomainTemplate = ( + template: string, + vars: { + appName: string; + prNumber: string; + branchName: string; + uniqueId: string; + }, +): string => { + return template + .replace(/\$\{appName\}/g, vars.appName) + .replace(/\$\{prNumber\}/g, vars.prNumber) + .replace(/\$\{branchName\}/g, slugify(vars.branchName)) + .replace(/\$\{uniqueId\}/g, vars.uniqueId); +}; + export const createPreviewDeployment = async ( schema: z.infer, ) => { const application = await findApplicationById(schema.applicationId); - const appName = `preview-${application.appName}-${generatePassword(6)}`; + const uniqueId = generatePassword(6); + const domainTemplate = application.previewWildcard || "*.sslip.io"; - const org = await db.query.organization.findFirst({ - where: eq(organization.id, application.environment.project.organizationId), - }); - const generateDomain = await generateWildcardDomain( - application.previewWildcard || "*.sslip.io", - appName, - application.server?.ipAddress || "", - org?.ownerId || "", - ); + const hasIdentifier = + domainTemplate.includes("${prNumber}") || + domainTemplate.includes("${branchName}") || + domainTemplate.includes("${uniqueId}"); + + const appName: string = `preview-${application.appName}-${uniqueId}`; + let generateDomain: string; + + if (hasIdentifier) { + const interpolated = interpolateSubdomainTemplate(domainTemplate, { + appName: application.appName, + prNumber: schema.pullRequestNumber, + branchName: schema.branch, + uniqueId, + }); + generateDomain = await generateWildcardDomain( + interpolated, + appName, + application.server?.ipAddress || "", + "", + ); + } else { + const org = await db.query.organization.findFirst({ + where: eq( + organization.id, + application.environment.project.organizationId, + ), + }); + generateDomain = await generateWildcardDomain( + domainTemplate, + appName, + application.server?.ipAddress || "", + org?.ownerId || "", + ); + } const octokit = authGithub(application?.github as Github);