Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/dokploy/__test__/preview-deployment/template.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string> = {
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);
Expand Down Expand Up @@ -171,7 +196,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{isTraefikMeDomain && (
{isSslipDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
Expand All @@ -189,11 +214,33 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
control={form.control}
name="wildcardDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormItem className="lg:col-span-2">
<FormLabel>Preview Domain Template</FormLabel>
<FormControl>
<Input placeholder="*.sslip.io" {...field} />
<Input
placeholder="*.sslip.io or ${prNumber}.example.com"
{...field}
/>
</FormControl>
<FormDescription className="flex flex-col gap-1.5">
<span>
Use <code className="text-xs">*.</code> for
auto-generated subdomains, or template variables for
custom patterns. If the template includes{" "}
<code className="text-xs">{"${prNumber}"}</code> or{" "}
<code className="text-xs">{"${branchName}"}</code>,
no random suffix is appended.
</span>
<span className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono">
<code>{"${appName}"}</code>
<code>{"${prNumber}"}</code>
<code>{"${branchName}"}</code>
<code>{"${uniqueId}"}</code>
</span>
<span className="text-xs mt-1 px-2 py-1 rounded bg-muted font-mono truncate">
Example: {templatePreview}
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
Expand Down
72 changes: 62 additions & 10 deletions packages/server/src/services/preview-deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof apiCreatePreviewDeployment>,
) => {
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);

Expand Down
Loading