diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7746a40c..23e8debb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: Create a bug report -labels: ["bug"] +labels: ["needs-triage🔍"] body: - type: markdown attributes: @@ -62,6 +62,7 @@ body: - "Docker" - "Remote server" - "Local Development" + - "Cloud Version" validations: required: true - type: dropdown diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 8bc9fbcc..c5f45810 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -9,6 +9,7 @@ describe("createDomainLabels", () => { port: 8080, https: false, uniqueConfigKey: 1, + customCertResolver: null, certificateType: "none", applicationId: "", composeId: "", diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts index 4e6f20d3..f5b68c1d 100644 --- a/apps/dokploy/__test__/drop/drop.test.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.test.ts @@ -27,6 +27,7 @@ if (typeof window === "undefined") { const baseApp: ApplicationNested = { applicationId: "", herokuVersion: "", + watchPaths: [], applicationStatus: "done", appName: "", autoDeploy: true, @@ -37,6 +38,7 @@ const baseApp: ApplicationNested = { isPreviewDeploymentsActive: false, previewBuildArgs: null, previewCertificateType: "none", + previewCustomCertResolver: null, previewEnv: null, previewHttps: false, previewPath: "/", diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts new file mode 100644 index 00000000..145e1372 --- /dev/null +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -0,0 +1,375 @@ +import { describe, expect, it } from "vitest"; +import type { CompleteTemplate } from "@dokploy/server/templates/processors"; +import { processTemplate } from "@dokploy/server/templates/processors"; +import type { Schema } from "@dokploy/server/templates"; + +describe("processTemplate", () => { + // Mock schema for testing + const mockSchema: Schema = { + projectName: "test", + serverIp: "127.0.0.1", + }; + + describe("variables processing", () => { + it("should process basic variables with utility functions", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + totp_key: "${base64:32}", + password: "${password:32}", + hash: "${hash:16}", + }, + config: { + domains: [], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(0); + expect(result.domains).toHaveLength(0); + expect(result.mounts).toHaveLength(0); + }); + + it("should allow referencing variables in other variables", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + api_domain: "api.${main_domain}", + }, + config: { + domains: [], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(0); + expect(result.domains).toHaveLength(0); + expect(result.mounts).toHaveLength(0); + }); + }); + + describe("domains processing", () => { + it("should process domains with explicit host", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + }, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${main_domain}", + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain) return; + expect(domain).toMatchObject({ + serviceName: "plausible", + port: 8000, + }); + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + + it("should generate random domain if host is not specified", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain || !domain.host) return; + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + + it("should allow using ${domain} directly in host", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${domain}", + }, + ], + env: {}, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.domains).toHaveLength(1); + const domain = result.domains[0]; + expect(domain).toBeDefined(); + if (!domain || !domain.host) return; + expect(domain.host).toBeDefined(); + expect(domain.host).toContain(mockSchema.projectName); + }); + }); + + describe("environment variables processing", () => { + it("should process env vars with variable references", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + }, + config: { + domains: [], + env: { + BASE_URL: "http://${main_domain}", + SECRET_KEY_BASE: "${secret_base}", + }, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(2); + const baseUrl = result.envs.find((env: string) => + env.startsWith("BASE_URL="), + ); + const secretKey = result.envs.find((env: string) => + env.startsWith("SECRET_KEY_BASE="), + ); + + expect(baseUrl).toBeDefined(); + expect(secretKey).toBeDefined(); + if (!baseUrl || !secretKey) return; + + expect(baseUrl).toContain(mockSchema.projectName); + expect(secretKey.split("=")[1]).toHaveLength(64); + }); + + it("should allow using utility functions directly in env vars", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: { + RANDOM_DOMAIN: "${domain}", + SECRET_KEY: "${base64:32}", + }, + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(2); + const randomDomainEnv = result.envs.find((env: string) => + env.startsWith("RANDOM_DOMAIN="), + ); + const secretKeyEnv = result.envs.find((env: string) => + env.startsWith("SECRET_KEY="), + ); + expect(randomDomainEnv).toBeDefined(); + expect(secretKeyEnv).toBeDefined(); + if (!randomDomainEnv || !secretKeyEnv) return; + + expect(randomDomainEnv).toContain(mockSchema.projectName); + expect(secretKeyEnv.split("=")[1]).toHaveLength(32); + }); + }); + + describe("mounts processing", () => { + it("should process mounts with variable references", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + config_path: "/etc/config", + secret_key: "${base64:32}", + }, + config: { + domains: [], + env: {}, + mounts: [ + { + filePath: "${config_path}/config.xml", + content: "secret_key=${secret_key}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.filePath).toContain("/etc/config"); + expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/); + }); + + it("should allow using utility functions directly in mount content", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [], + env: {}, + mounts: [ + { + filePath: "/config/secrets.txt", + content: "random_domain=${domain}\nsecret=${base64:32}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.content).toContain(mockSchema.projectName); + expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/); + }); + }); + + describe("complex template processing", () => { + it("should process a complete template with all features", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + main_domain: "${domain}", + secret_base: "${base64:64}", + totp_key: "${base64:32}", + }, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${main_domain}", + }, + { + serviceName: "api", + port: 3000, + host: "api.${main_domain}", + }, + ], + env: { + BASE_URL: "http://${main_domain}", + SECRET_KEY_BASE: "${secret_base}", + TOTP_VAULT_KEY: "${totp_key}", + }, + mounts: [ + { + filePath: "/config/app.conf", + content: ` + domain=\${main_domain} + secret=\${secret_base} + totp=\${totp_key} + `, + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + + // Check domains + expect(result.domains).toHaveLength(2); + const [domain1, domain2] = result.domains; + expect(domain1).toBeDefined(); + expect(domain2).toBeDefined(); + if (!domain1 || !domain2) return; + expect(domain1.host).toBeDefined(); + expect(domain1.host).toContain(mockSchema.projectName); + expect(domain2.host).toContain("api."); + expect(domain2.host).toContain(mockSchema.projectName); + + // Check env vars + expect(result.envs).toHaveLength(3); + const baseUrl = result.envs.find((env: string) => + env.startsWith("BASE_URL="), + ); + const secretKey = result.envs.find((env: string) => + env.startsWith("SECRET_KEY_BASE="), + ); + const totpKey = result.envs.find((env: string) => + env.startsWith("TOTP_VAULT_KEY="), + ); + + expect(baseUrl).toBeDefined(); + expect(secretKey).toBeDefined(); + expect(totpKey).toBeDefined(); + if (!baseUrl || !secretKey || !totpKey) return; + + expect(baseUrl).toContain(mockSchema.projectName); + expect(secretKey.split("=")[1]).toHaveLength(64); + expect(totpKey.split("=")[1]).toHaveLength(32); + + // Check mounts + expect(result.mounts).toHaveLength(1); + const mount = result.mounts[0]; + expect(mount).toBeDefined(); + if (!mount) return; + expect(mount.content).toContain(mockSchema.projectName); + expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{64}/); + expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{32}/); + }); + }); + + describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => { + it("should populate envs, domains and mounts in the case we didn't used any variable", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: {}, + config: { + domains: [ + { + serviceName: "plausible", + port: 8000, + host: "${hash}", + }, + ], + env: { + BASE_URL: "http://${domain}", + SECRET_KEY_BASE: "${password:32}", + TOTP_VAULT_KEY: "${base64:128}", + }, + mounts: [ + { + filePath: "/config/secrets.txt", + content: "random_domain=${domain}\nsecret=${password:32}", + }, + ], + }, + }; + + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(3); + expect(result.domains).toHaveLength(1); + expect(result.mounts).toHaveLength(1); + }); + }); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index c72d7254..f33b37fd 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -47,7 +47,7 @@ const baseAdmin: User = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, - enableLogRotation: false, + logCleanupCron: null, serversQuantity: 0, stripeCustomerId: "", stripeSubscriptionId: "", diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 955103de..a64103ff 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -14,6 +14,7 @@ const baseApp: ApplicationNested = { branch: null, dockerBuildStage: "", registryUrl: "", + watchPaths: [], buildArgs: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, @@ -23,6 +24,7 @@ const baseApp: ApplicationNested = { previewPath: "/", previewPort: 3000, previewLimit: 0, + previewCustomCertResolver: null, previewWildcard: "", project: { env: "", @@ -103,6 +105,7 @@ const baseDomain: Domain = { port: null, serviceName: "", composeId: "", + customCertResolver: null, domainType: "application", uniqueConfigKey: 1, previewDeploymentId: "", diff --git a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx new file mode 100644 index 00000000..2a3f2f43 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -0,0 +1,347 @@ +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Code2, Globe2, HardDrive } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; + +const ImportSchema = z.object({ + base64: z.string(), +}); + +type ImportType = z.infer; + +interface Props { + composeId: string; +} + +export const ShowImport = ({ composeId }: Props) => { + const [showModal, setShowModal] = useState(false); + const [showMountContent, setShowMountContent] = useState(false); + const [selectedMount, setSelectedMount] = useState<{ + filePath: string; + content: string; + } | null>(null); + const [templateInfo, setTemplateInfo] = useState<{ + compose: string; + template: { + domains: Array<{ + serviceName: string; + port: number; + path?: string; + host?: string; + }>; + envs: string[]; + mounts: Array<{ + filePath: string; + content: string; + }>; + }; + } | null>(null); + + const utils = api.useUtils(); + const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } = + api.compose.processTemplate.useMutation(); + const { + mutateAsync: importTemplate, + isLoading: isImporting, + isSuccess: isImportSuccess, + } = api.compose.import.useMutation(); + + const form = useForm({ + defaultValues: { + base64: "", + }, + resolver: zodResolver(ImportSchema), + }); + + useEffect(() => { + form.reset({ + base64: "", + }); + }, [isImportSuccess]); + + const onSubmit = async () => { + const base64 = form.getValues("base64"); + if (!base64) { + toast.error("Please enter a base64 template"); + return; + } + + try { + await importTemplate({ + composeId, + base64, + }); + toast.success("Template imported successfully"); + await utils.compose.one.invalidate({ + composeId, + }); + setShowModal(false); + } catch (_error) { + toast.error("Error importing template"); + } + }; + + const handleLoadTemplate = async () => { + const base64 = form.getValues("base64"); + if (!base64) { + toast.error("Please enter a base64 template"); + return; + } + + try { + const result = await processTemplate({ + composeId, + base64, + }); + setTemplateInfo(result); + setShowModal(true); + } catch (_error) { + toast.error("Error processing template"); + } + }; + + const handleShowMountContent = (mount: { + filePath: string; + content: string; + }) => { + setSelectedMount(mount); + setShowMountContent(true); + }; + + return ( + <> + + + Import + Import your Template configuration + + + + Warning: Importing a template will remove all existing environment + variables, mounts, and domains from this service. + +
+ + ( + + Configuration (Base64) + +