mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
refactor: update template system with new configuration structure and processing
This commit is contained in:
@@ -70,6 +70,38 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface TemplateData {
|
||||||
|
metadata: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
logo: string;
|
||||||
|
links: {
|
||||||
|
github: string;
|
||||||
|
website?: string;
|
||||||
|
docs?: string;
|
||||||
|
};
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
variables: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
env: Record<string, string>;
|
||||||
|
mounts?: Array<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
@@ -94,11 +126,13 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
data?.filter((template) => {
|
data?.filter((template) => {
|
||||||
const matchesTags =
|
const matchesTags =
|
||||||
selectedTags.length === 0 ||
|
selectedTags.length === 0 ||
|
||||||
template.tags.some((tag) => selectedTags.includes(tag));
|
template.metadata.tags.some((tag) => selectedTags.includes(tag));
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
query === "" ||
|
query === "" ||
|
||||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
template.metadata.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
template.description.toLowerCase().includes(query.toLowerCase());
|
template.metadata.description
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase());
|
||||||
return matchesTags && matchesQuery;
|
return matchesTags && matchesQuery;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
@@ -249,9 +283,9 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
|
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{templates?.map((template, index) => (
|
{templates?.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={`template-${index}`}
|
key={template.metadata.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||||
viewMode === "icon" && "h-[200px]",
|
viewMode === "icon" && "h-[200px]",
|
||||||
@@ -259,7 +293,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Badge className="absolute top-2 right-2" variant="blue">
|
<Badge className="absolute top-2 right-2" variant="blue">
|
||||||
{template.version}
|
{template.metadata.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Template Header */}
|
{/* Template Header */}
|
||||||
<div
|
<div
|
||||||
@@ -269,21 +303,21 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/templates/${template.logo}`}
|
src={`/templates/${template.metadata.logo}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
viewMode === "detailed" ? "size-24" : "size-16",
|
viewMode === "detailed" ? "size-24" : "size-16",
|
||||||
)}
|
)}
|
||||||
alt={template.name}
|
alt={template.metadata.name}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-sm font-medium line-clamp-1">
|
<span className="text-sm font-medium line-clamp-1">
|
||||||
{template.name}
|
{template.metadata.name}
|
||||||
</span>
|
</span>
|
||||||
{viewMode === "detailed" &&
|
{viewMode === "detailed" &&
|
||||||
template.tags.length > 0 && (
|
template.metadata.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{template.tags.map((tag) => (
|
{template.metadata.tags.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="green"
|
variant="green"
|
||||||
@@ -301,7 +335,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{template.description}
|
{template.metadata.description}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
@@ -318,24 +352,24 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={template.links.github}
|
href={template.metadata.links.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Github className="size-5" />
|
<Github className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
{template.links.website && (
|
{template.metadata.links.website && (
|
||||||
<Link
|
<Link
|
||||||
href={template.links.website}
|
href={template.metadata.links.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="size-5" />
|
<Globe className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{template.links.docs && (
|
{template.metadata.links.docs && (
|
||||||
<Link
|
<Link
|
||||||
href={template.links.docs}
|
href={template.metadata.links.docs}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -364,8 +398,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will create an application from the{" "}
|
This will create an application from the{" "}
|
||||||
{template.name} template and add it to your
|
{template.metadata.name} template and add it to
|
||||||
project.
|
your project.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -431,7 +465,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
const promise = mutateAsync({
|
const promise = mutateAsync({
|
||||||
projectId,
|
projectId,
|
||||||
serverId: serverId || undefined,
|
serverId: serverId || undefined,
|
||||||
id: template.id,
|
id: template.metadata.id,
|
||||||
});
|
});
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: "Setting up...",
|
loading: "Setting up...",
|
||||||
@@ -440,10 +474,10 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return `${template.name} template created successfully`;
|
return `${template.metadata.name} template created successfully`;
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
return `An error ocurred deploying ${template.name} template`;
|
return `An error occurred deploying ${template.metadata.name} template`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import {
|
|||||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||||
import { templates } from "@/templates/templates";
|
import { templates } from "@/templates/templates";
|
||||||
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
|
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
|
||||||
|
import { generatePassword } from "@/templates/utils";
|
||||||
import {
|
import {
|
||||||
generatePassword,
|
fetchTemplateFiles,
|
||||||
loadTemplateModule,
|
fetchTemplatesList,
|
||||||
readTemplateComposeFile,
|
processTemplate,
|
||||||
} from "@/templates/utils";
|
} from "@dokploy/server/templates/utils/github";
|
||||||
import { fetchTemplatesList } from "@dokploy/server/templates/utils/github";
|
import { readTemplateComposeFile } from "@dokploy/server/templates/utils";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { dump } from "js-yaml";
|
import { dump } from "js-yaml";
|
||||||
@@ -56,6 +57,29 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { TemplateConfig } from "@dokploy/server/types/template";
|
||||||
|
|
||||||
|
// Add the template config schema
|
||||||
|
const templateConfigSchema = z.object({
|
||||||
|
variables: z.record(z.string()),
|
||||||
|
domains: z.array(
|
||||||
|
z.object({
|
||||||
|
serviceName: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
host: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
env: z.record(z.string()),
|
||||||
|
mounts: z.array(
|
||||||
|
z.object({
|
||||||
|
filePath: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}) satisfies z.ZodType<TemplateConfig>;
|
||||||
|
|
||||||
export const composeRouter = createTRPCRouter({
|
export const composeRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateCompose)
|
.input(apiCreateCompose)
|
||||||
@@ -374,7 +398,13 @@ export const composeRouter = createTRPCRouter({
|
|||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
deployTemplate: protectedProcedure
|
deployTemplate: protectedProcedure
|
||||||
.input(apiCreateComposeByTemplate)
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.user.rol === "user") {
|
if (ctx.user.rol === "user") {
|
||||||
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
|
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
|
||||||
@@ -387,9 +417,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const composeFile = await readTemplateComposeFile(input.id);
|
const template = await fetchTemplateFiles(input.id);
|
||||||
|
|
||||||
const generate = await loadTemplateModule(input.id);
|
|
||||||
|
|
||||||
const admin = await findAdminById(ctx.user.adminId);
|
const admin = await findAdminById(ctx.user.adminId);
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
let serverIp = admin.serverIp || "127.0.0.1";
|
||||||
@@ -402,50 +430,60 @@ export const composeRouter = createTRPCRouter({
|
|||||||
} else if (process.env.NODE_ENV === "development") {
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
serverIp = "127.0.0.1";
|
serverIp = "127.0.0.1";
|
||||||
}
|
}
|
||||||
const projectName = slugify(`${project.name} ${input.id}`);
|
|
||||||
const { envs, mounts, domains } = await generate({
|
const generate = processTemplate(template.config, {
|
||||||
serverIp: serverIp || "",
|
serverIp: serverIp,
|
||||||
projectName: projectName,
|
projectName: project.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
const compose = await createComposeByTemplate({
|
console.log(generate.domains);
|
||||||
...input,
|
console.log(generate.envs);
|
||||||
composeFile: composeFile,
|
console.log(generate.mounts);
|
||||||
env: envs?.join("\n"),
|
|
||||||
serverId: input.serverId,
|
|
||||||
name: input.id,
|
|
||||||
sourceType: "raw",
|
|
||||||
appName: `${projectName}-${generatePassword(6)}`,
|
|
||||||
isolatedDeployment: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ctx.user.rol === "user") {
|
// const projectName = slugify(`${project.name} ${input.id}`);
|
||||||
await addNewService(ctx.user.authId, compose.composeId);
|
// const { envs, mounts, domains } = await generate({
|
||||||
}
|
// serverIp: serverIp || "",
|
||||||
|
// projectName: projectName,
|
||||||
|
// });
|
||||||
|
|
||||||
if (mounts && mounts?.length > 0) {
|
// const compose = await createComposeByTemplate({
|
||||||
for (const mount of mounts) {
|
// ...input,
|
||||||
await createMount({
|
// composeFile: composeFile,
|
||||||
filePath: mount.filePath,
|
// env: envs?.join("\n"),
|
||||||
mountPath: "",
|
// serverId: input.serverId,
|
||||||
content: mount.content,
|
// name: input.id,
|
||||||
serviceId: compose.composeId,
|
// sourceType: "raw",
|
||||||
serviceType: "compose",
|
// appName: `${projectName}-${generatePassword(6)}`,
|
||||||
type: "file",
|
// isolatedDeployment: true,
|
||||||
});
|
// });
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domains && domains?.length > 0) {
|
// if (ctx.user.rol === "user") {
|
||||||
for (const domain of domains) {
|
// await addNewService(ctx.user.authId, compose.composeId);
|
||||||
await createDomain({
|
// }
|
||||||
...domain,
|
|
||||||
domainType: "compose",
|
// if (mounts && mounts?.length > 0) {
|
||||||
certificateType: "none",
|
// for (const mount of mounts) {
|
||||||
composeId: compose.composeId,
|
// await createMount({
|
||||||
});
|
// filePath: mount.filePath,
|
||||||
}
|
// mountPath: "",
|
||||||
}
|
// content: mount.content,
|
||||||
|
// serviceId: compose.composeId,
|
||||||
|
// serviceType: "compose",
|
||||||
|
// type: "file",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (domains && domains?.length > 0) {
|
||||||
|
// for (const domain of domains) {
|
||||||
|
// await createDomain({
|
||||||
|
// ...domain,
|
||||||
|
// domainType: "compose",
|
||||||
|
// certificateType: "none",
|
||||||
|
// composeId: compose.composeId,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
@@ -465,7 +503,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to local templates if GitHub fetch fails
|
// Fall back to local templates if GitHub fetch fails
|
||||||
return templates;
|
return [];
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getTags: protectedProcedure.query(async ({ input }) => {
|
getTags: protectedProcedure.query(async ({ input }) => {
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
pocketbase:
|
|
||||||
image: spectado/pocketbase:0.23.3
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- /etc/dokploy/templates/${HASH}/data:/pb_data
|
|
||||||
- /etc/dokploy/templates/${HASH}/public:/pb_public
|
|
||||||
- /etc/dokploy/templates/${HASH}/migrations:/pb_migrations
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import {
|
|
||||||
type DomainSchema,
|
|
||||||
type Schema,
|
|
||||||
type Template,
|
|
||||||
generateRandomDomain,
|
|
||||||
} from "../utils";
|
|
||||||
|
|
||||||
export function generate(schema: Schema): Template {
|
|
||||||
const mainDomain = generateRandomDomain(schema);
|
|
||||||
|
|
||||||
const domains: DomainSchema[] = [
|
|
||||||
{
|
|
||||||
host: mainDomain,
|
|
||||||
port: 80,
|
|
||||||
serviceName: "pocketbase",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
domains,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -47,21 +47,6 @@ export const templates: TemplateData[] = [
|
|||||||
load: () => import("./supabase/index").then((m) => m.generate),
|
load: () => import("./supabase/index").then((m) => m.generate),
|
||||||
tags: ["database", "firebase", "postgres"],
|
tags: ["database", "firebase", "postgres"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "pocketbase",
|
|
||||||
name: "Pocketbase",
|
|
||||||
version: "v0.22.12",
|
|
||||||
description:
|
|
||||||
"Pocketbase is a self-hosted alternative to Firebase that allows you to build and host your own backend services.",
|
|
||||||
links: {
|
|
||||||
github: "https://github.com/pocketbase/pocketbase",
|
|
||||||
website: "https://pocketbase.io/",
|
|
||||||
docs: "https://pocketbase.io/docs/",
|
|
||||||
},
|
|
||||||
logo: "pocketbase.svg",
|
|
||||||
load: () => import("./pocketbase/index").then((m) => m.generate),
|
|
||||||
tags: ["database", "cms", "headless"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "plausible",
|
id: "plausible",
|
||||||
name: "Plausible",
|
name: "Plausible",
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export interface Template {
|
|||||||
export const generateRandomDomain = ({
|
export const generateRandomDomain = ({
|
||||||
serverIp,
|
serverIp,
|
||||||
projectName,
|
projectName,
|
||||||
}: Schema): string => {
|
}: {
|
||||||
|
serverIp: string;
|
||||||
|
projectName: string;
|
||||||
|
}): string => {
|
||||||
const hash = randomBytes(3).toString("hex");
|
const hash = randomBytes(3).toString("hex");
|
||||||
const slugIp = serverIp.replaceAll(".", "-");
|
const slugIp = serverIp.replaceAll(".", "-");
|
||||||
|
|
||||||
@@ -41,9 +44,15 @@ export const generateHash = (projectName: string, quantity = 3): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generatePassword = (quantity = 16): string => {
|
export const generatePassword = (quantity = 16): string => {
|
||||||
return randomBytes(Math.ceil(quantity / 2))
|
const characters =
|
||||||
.toString("hex")
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
.slice(0, quantity);
|
let password = "";
|
||||||
|
for (let i = 0; i < quantity; i++) {
|
||||||
|
password += characters.charAt(
|
||||||
|
Math.floor(Math.random() * characters.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return password.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateBase64 = (bytes = 32): string => {
|
export const generateBase64 = (bytes = 32): string => {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { execSync } from "node:child_process";
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
import { load } from "js-yaml";
|
import { load } from "js-yaml";
|
||||||
import { templateConfig } from "../config";
|
import { templateConfig } from "../config";
|
||||||
import type { Template } from "./index";
|
import type { Schema, Template, DomainSchema } from "./index";
|
||||||
import {
|
import {
|
||||||
generateBase64,
|
generateBase64,
|
||||||
generateHash,
|
generateHash,
|
||||||
@@ -21,20 +19,179 @@ const octokit = new Octokit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for template metadata
|
* Complete template interface that includes both metadata and configuration
|
||||||
*/
|
*/
|
||||||
export interface TemplateMetadata {
|
export interface CompleteTemplate {
|
||||||
id: string;
|
metadata: {
|
||||||
name: string;
|
id: string;
|
||||||
version: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
logo: string;
|
tags: string[];
|
||||||
links: {
|
version: string;
|
||||||
github?: string;
|
logo: string;
|
||||||
website?: string;
|
links: {
|
||||||
docs?: string;
|
github: string;
|
||||||
|
website?: string;
|
||||||
|
docs?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
tags: string[];
|
variables: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
env: Record<string, string>;
|
||||||
|
mounts?: Array<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility functions that can be used in template values
|
||||||
|
*/
|
||||||
|
const TEMPLATE_FUNCTIONS = {
|
||||||
|
$randomDomain: () => true,
|
||||||
|
$password: (length = 16) => `$password(${length})`,
|
||||||
|
$base64: (bytes = 32) => `$base64(${bytes})`,
|
||||||
|
$base32: (bytes = 32) => `$base32(${bytes})`,
|
||||||
|
$hash: (length = 8) => `$hash(${length})`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a string value and replace variables
|
||||||
|
*/
|
||||||
|
function processValue(
|
||||||
|
value: string,
|
||||||
|
variables: Record<string, string>,
|
||||||
|
schema: Schema,
|
||||||
|
): string {
|
||||||
|
// First replace utility functions
|
||||||
|
let processedValue = value.replace(/\${([^}]+)}/g, (match, varName) => {
|
||||||
|
// Handle utility functions
|
||||||
|
if (varName === "randomDomain") {
|
||||||
|
return generateRandomDomain(schema);
|
||||||
|
}
|
||||||
|
if (varName.startsWith("base64:")) {
|
||||||
|
const length = Number.parseInt(varName.split(":")[1], 10) || 32;
|
||||||
|
return generateBase64(length);
|
||||||
|
}
|
||||||
|
if (varName.startsWith("base32:")) {
|
||||||
|
const length = Number.parseInt(varName.split(":")[1], 10) || 32;
|
||||||
|
return Buffer.from(randomBytes(length))
|
||||||
|
.toString("base64")
|
||||||
|
.substring(0, length);
|
||||||
|
}
|
||||||
|
if (varName.startsWith("password:")) {
|
||||||
|
const length = Number.parseInt(varName.split(":")[1], 10) || 16;
|
||||||
|
return generatePassword(length);
|
||||||
|
}
|
||||||
|
if (varName.startsWith("hash:")) {
|
||||||
|
const length = Number.parseInt(varName.split(":")[1], 10) || 8;
|
||||||
|
return generateHash(length);
|
||||||
|
}
|
||||||
|
// If not a utility function, try to get from variables
|
||||||
|
return variables[varName] || match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then replace any remaining ${var} with their values from variables
|
||||||
|
processedValue = processedValue.replace(/\${([^}]+)}/g, (match, varName) => {
|
||||||
|
return variables[varName] || match;
|
||||||
|
});
|
||||||
|
|
||||||
|
return processedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a template configuration and returns the generated template
|
||||||
|
*/
|
||||||
|
export function processTemplate(
|
||||||
|
config: CompleteTemplate,
|
||||||
|
schema: Schema,
|
||||||
|
): Template {
|
||||||
|
const result: Template = {
|
||||||
|
envs: [],
|
||||||
|
domains: [],
|
||||||
|
mounts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// First pass: Process variables that don't depend on domains
|
||||||
|
const variables: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(config.variables)) {
|
||||||
|
if (value === "${randomDomain}") {
|
||||||
|
variables[key] = generateRandomDomain(schema);
|
||||||
|
} else if (value.startsWith("${base64:")) {
|
||||||
|
const match = value.match(/\${base64:(\d+)}/);
|
||||||
|
const length = match?.[1] ? Number.parseInt(match[1], 10) : 32;
|
||||||
|
variables[key] = generateBase64(length);
|
||||||
|
} else if (value.startsWith("${base32:")) {
|
||||||
|
const match = value.match(/\${base32:(\d+)}/);
|
||||||
|
const length = match?.[1] ? Number.parseInt(match[1], 10) : 32;
|
||||||
|
variables[key] = Buffer.from(randomBytes(length))
|
||||||
|
.toString("base64")
|
||||||
|
.substring(0, length);
|
||||||
|
} else if (value.startsWith("${password:")) {
|
||||||
|
const match = value.match(/\${password:(\d+)}/);
|
||||||
|
const length = match?.[1] ? Number.parseInt(match[1], 10) : 16;
|
||||||
|
variables[key] = generatePassword(length);
|
||||||
|
} else if (value.startsWith("${hash:")) {
|
||||||
|
const match = value.match(/\${hash:(\d+)}/);
|
||||||
|
const length = match?.[1] ? Number.parseInt(match[1], 10) : 8;
|
||||||
|
variables[key] = generateHash(length);
|
||||||
|
} else {
|
||||||
|
variables[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(variables);
|
||||||
|
|
||||||
|
// Process domains and add them to variables
|
||||||
|
for (const domain of config.config.domains) {
|
||||||
|
// If host is specified, process it with variables, otherwise generate random domain
|
||||||
|
const host = domain.host
|
||||||
|
? processValue(domain.host, variables, schema)
|
||||||
|
: generateRandomDomain(schema);
|
||||||
|
|
||||||
|
result.domains.push({
|
||||||
|
host,
|
||||||
|
...domain,
|
||||||
|
});
|
||||||
|
// Add domain to variables for reference
|
||||||
|
variables[`domain:${domain.serviceName}`] = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process environment variables with access to all variables
|
||||||
|
for (const [key, value] of Object.entries(config.config.env)) {
|
||||||
|
const processedValue = processValue(value, variables, schema);
|
||||||
|
result.envs.push(`${key}=${processedValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process mounts with access to all variables
|
||||||
|
if (config.config.mounts) {
|
||||||
|
for (const mount of config.config.mounts) {
|
||||||
|
result.mounts.push({
|
||||||
|
filePath: mount.filePath,
|
||||||
|
content: processValue(mount.content, variables, schema),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub tree item with required fields
|
||||||
|
*/
|
||||||
|
interface GitTreeItem {
|
||||||
|
path: string;
|
||||||
|
type: string;
|
||||||
|
sha: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,62 +201,54 @@ export async function fetchTemplatesList(
|
|||||||
owner = templateConfig.owner,
|
owner = templateConfig.owner,
|
||||||
repo = templateConfig.repo,
|
repo = templateConfig.repo,
|
||||||
branch = templateConfig.branch,
|
branch = templateConfig.branch,
|
||||||
): Promise<TemplateMetadata[]> {
|
): Promise<CompleteTemplate[]> {
|
||||||
try {
|
try {
|
||||||
// Fetch templates directory content
|
// First get the tree SHA for the branch
|
||||||
const { data: dirContent } = await octokit.repos.getContent({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
path: "templates",
|
ref: `heads/${branch}`,
|
||||||
ref: branch,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("DIR CONTENT", dirContent);
|
// Get the full tree recursively
|
||||||
|
const { data: tree } = await octokit.git.getTree({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tree_sha: ref.object.sha,
|
||||||
|
recursive: "true",
|
||||||
|
});
|
||||||
|
|
||||||
if (!Array.isArray(dirContent)) {
|
// Filter for template.yml files in the templates directory
|
||||||
throw new Error("Templates directory not found or is not a directory");
|
const templateFiles = tree.tree.filter((item): item is GitTreeItem => {
|
||||||
}
|
return (
|
||||||
|
item.type === "blob" &&
|
||||||
|
typeof item.path === "string" &&
|
||||||
|
typeof item.sha === "string" &&
|
||||||
|
item.path.startsWith("templates/") &&
|
||||||
|
item.path.endsWith("/template.yml")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Filter for directories only (each directory is a template)
|
// Fetch and parse each template.yml
|
||||||
const templateDirs = dirContent.filter((item) => item.type === "dir");
|
|
||||||
|
|
||||||
// Fetch metadata for each template
|
|
||||||
const templates = await Promise.all(
|
const templates = await Promise.all(
|
||||||
templateDirs.map(async (dir) => {
|
templateFiles.map(async (file) => {
|
||||||
try {
|
try {
|
||||||
// Try to fetch metadata.json for each template
|
const { data: content } = await octokit.git.getBlob({
|
||||||
const { data: metadataFile } = await octokit.repos.getContent({
|
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
path: `templates/${dir.name}/metadata.json`,
|
file_sha: file.sha,
|
||||||
ref: branch,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("content" in metadataFile && metadataFile.encoding === "base64") {
|
const decoded = Buffer.from(content.content, "base64").toString();
|
||||||
const content = Buffer.from(
|
return load(decoded) as CompleteTemplate;
|
||||||
metadataFile.content,
|
|
||||||
"base64",
|
|
||||||
).toString();
|
|
||||||
return JSON.parse(content) as TemplateMetadata;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If metadata.json doesn't exist, create a basic metadata object
|
console.warn(`Failed to load template from ${file.path}:`, error);
|
||||||
return {
|
return null;
|
||||||
id: dir.name,
|
|
||||||
name: dir.name.charAt(0).toUpperCase() + dir.name.slice(1),
|
|
||||||
version: "latest",
|
|
||||||
description: `${dir.name} template`,
|
|
||||||
logo: "default.svg",
|
|
||||||
links: {},
|
|
||||||
tags: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return templates.filter(Boolean) as TemplateMetadata[];
|
return templates.filter(Boolean) as CompleteTemplate[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching templates list:", error);
|
console.error("Error fetching templates list:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -114,32 +263,73 @@ export async function fetchTemplateFiles(
|
|||||||
owner = templateConfig.owner,
|
owner = templateConfig.owner,
|
||||||
repo = templateConfig.repo,
|
repo = templateConfig.repo,
|
||||||
branch = templateConfig.branch,
|
branch = templateConfig.branch,
|
||||||
): Promise<{ indexTs: string; dockerCompose: string }> {
|
): Promise<{ config: CompleteTemplate; dockerCompose: string }> {
|
||||||
try {
|
try {
|
||||||
// Fetch index.ts
|
// Get the tree SHA for the branch
|
||||||
const { data: indexFile } = await octokit.repos.getContent({
|
const { data: ref } = await octokit.git.getRef({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
path: `templates/${templateId}/index.ts`,
|
ref: `heads/${branch}`,
|
||||||
ref: branch,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch docker-compose.yml
|
// Get the full tree recursively
|
||||||
const { data: composeFile } = await octokit.repos.getContent({
|
const { data: tree } = await octokit.git.getTree({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
path: `templates/${templateId}/docker-compose.yml`,
|
tree_sha: ref.object.sha,
|
||||||
ref: branch,
|
recursive: "true",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!("content" in indexFile) || !("content" in composeFile)) {
|
// Find the template.yml and docker-compose.yml files
|
||||||
|
const templateYml = tree.tree
|
||||||
|
.filter((item): item is GitTreeItem => {
|
||||||
|
return (
|
||||||
|
item.type === "blob" &&
|
||||||
|
typeof item.path === "string" &&
|
||||||
|
typeof item.sha === "string"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.find((item) => item.path === `templates/${templateId}/template.yml`);
|
||||||
|
|
||||||
|
const dockerComposeYml = tree.tree
|
||||||
|
.filter((item): item is GitTreeItem => {
|
||||||
|
return (
|
||||||
|
item.type === "blob" &&
|
||||||
|
typeof item.path === "string" &&
|
||||||
|
typeof item.sha === "string"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.find(
|
||||||
|
(item) => item.path === `templates/${templateId}/docker-compose.yml`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!templateYml || !dockerComposeYml) {
|
||||||
throw new Error("Template files not found");
|
throw new Error("Template files not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexTs = Buffer.from(indexFile.content, "base64").toString();
|
// Fetch both files in parallel
|
||||||
const dockerCompose = Buffer.from(composeFile.content, "base64").toString();
|
const [templateContent, composeContent] = await Promise.all([
|
||||||
|
octokit.git.getBlob({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
file_sha: templateYml.sha,
|
||||||
|
}),
|
||||||
|
octokit.git.getBlob({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
file_sha: dockerComposeYml.sha,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return { indexTs, dockerCompose };
|
const config = load(
|
||||||
|
Buffer.from(templateContent.data.content, "base64").toString(),
|
||||||
|
) as CompleteTemplate;
|
||||||
|
const dockerCompose = Buffer.from(
|
||||||
|
composeContent.data.content,
|
||||||
|
"base64",
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
return { config, dockerCompose };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching template ${templateId}:`, error);
|
console.error(`Error fetching template ${templateId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -147,179 +337,11 @@ export async function fetchTemplateFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the template's index.ts code dynamically
|
* Loads and processes a template
|
||||||
* Uses a template-based approach that's safer and more efficient
|
|
||||||
*/
|
*/
|
||||||
export async function executeTemplateCode(
|
export async function loadTemplateModule(
|
||||||
indexTsCode: string,
|
id: string,
|
||||||
schema: { serverIp: string; projectName: string },
|
): Promise<(schema: Schema) => Promise<Template>> {
|
||||||
): Promise<Template> {
|
const { config } = await fetchTemplateFiles(id);
|
||||||
try {
|
return async (schema: Schema) => processTemplate(config, schema);
|
||||||
// Create a temporary directory for the template
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const tempId = randomBytes(8).toString("hex");
|
|
||||||
const tempDir = join(cwd, ".next", "temp", tempId);
|
|
||||||
|
|
||||||
if (!existsSync(tempDir)) {
|
|
||||||
await mkdir(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the generate function body
|
|
||||||
// This approach assumes templates follow a standard structure with a generate function
|
|
||||||
const generateFunctionMatch = indexTsCode.match(
|
|
||||||
/export\s+function\s+generate\s*\([^)]*\)\s*{([\s\S]*?)return\s+{([\s\S]*?)};?\s*}/,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!generateFunctionMatch) {
|
|
||||||
throw new Error("Could not extract generate function from template");
|
|
||||||
}
|
|
||||||
|
|
||||||
const functionBody = generateFunctionMatch[1];
|
|
||||||
const returnStatement = generateFunctionMatch[2];
|
|
||||||
|
|
||||||
// Create a simplified template that doesn't require imports
|
|
||||||
const templateCode = `
|
|
||||||
// Utility functions provided to the template
|
|
||||||
function generateRandomDomain(schema) {
|
|
||||||
const hash = Math.random().toString(36).substring(2, 8);
|
|
||||||
const slugIp = schema.serverIp.replaceAll(".", "-");
|
|
||||||
return \`\${schema.projectName}-\${hash}\${slugIp === "" ? "" : \`-\${slugIp}\`}.traefik.me\`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHash(projectName, quantity = 3) {
|
|
||||||
const hash = Math.random().toString(36).substring(2, 2 + quantity);
|
|
||||||
return \`\${projectName}-\${hash}\`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePassword(quantity = 16) {
|
|
||||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let password = "";
|
|
||||||
for (let i = 0; i < quantity; i++) {
|
|
||||||
password += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
||||||
}
|
|
||||||
return password.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateBase64(bytes = 32) {
|
|
||||||
return Math.random().toString(36).substring(2, 2 + bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template execution
|
|
||||||
function execute(schema) {
|
|
||||||
${functionBody}
|
|
||||||
return {
|
|
||||||
${returnStatement}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run with the provided schema and output the result
|
|
||||||
const result = execute(${JSON.stringify(schema)});
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Write the template code to a file
|
|
||||||
const templatePath = join(tempDir, "template.js");
|
|
||||||
await writeFile(templatePath, templateCode, "utf8");
|
|
||||||
|
|
||||||
// Execute the template using Node.js
|
|
||||||
const output = execSync(`node ${templatePath}`, {
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse the output as JSON
|
|
||||||
return JSON.parse(output);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error executing template code:", error);
|
|
||||||
|
|
||||||
// Fallback to a simpler approach if the template extraction fails
|
|
||||||
return fallbackExecuteTemplate(indexTsCode, schema);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback method to execute templates that don't follow the standard structure
|
|
||||||
*/
|
|
||||||
async function fallbackExecuteTemplate(
|
|
||||||
indexTsCode: string,
|
|
||||||
schema: { serverIp: string; projectName: string },
|
|
||||||
): Promise<Template> {
|
|
||||||
try {
|
|
||||||
// Create a temporary directory
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const tempId = randomBytes(8).toString("hex");
|
|
||||||
const tempDir = join(cwd, ".next", "temp", tempId);
|
|
||||||
|
|
||||||
if (!existsSync(tempDir)) {
|
|
||||||
await mkdir(tempDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a simplified version of the template code
|
|
||||||
// Remove TypeScript types and imports
|
|
||||||
const simplifiedCode = indexTsCode
|
|
||||||
.replace(/import\s+.*?from\s+['"].*?['"]\s*;?/g, "")
|
|
||||||
.replace(/export\s+interface\s+.*?{[\s\S]*?}/g, "")
|
|
||||||
.replace(/:\s*Schema/g, "")
|
|
||||||
.replace(/:\s*DomainSchema/g, "")
|
|
||||||
.replace(/:\s*Template/g, "")
|
|
||||||
.replace(/:\s*string/g, "")
|
|
||||||
.replace(/:\s*number/g, "")
|
|
||||||
.replace(/:\s*boolean/g, "")
|
|
||||||
.replace(/:\s*any/g, "")
|
|
||||||
.replace(/:\s*unknown/g, "")
|
|
||||||
.replace(/<.*?>/g, "");
|
|
||||||
|
|
||||||
// Create a wrapper with all necessary utilities
|
|
||||||
const wrapperCode = `
|
|
||||||
// Utility functions
|
|
||||||
function generateRandomDomain(schema) {
|
|
||||||
const hash = Math.random().toString(36).substring(2, 8);
|
|
||||||
const slugIp = schema.serverIp.replaceAll(".", "-");
|
|
||||||
return \`\${schema.projectName}-\${hash}\${slugIp === "" ? "" : \`-\${slugIp}\`}.traefik.me\`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateHash(projectName, quantity = 3) {
|
|
||||||
const hash = Math.random().toString(36).substring(2, 2 + quantity);
|
|
||||||
return \`\${projectName}-\${hash}\`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePassword(quantity = 16) {
|
|
||||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let password = "";
|
|
||||||
for (let i = 0; i < quantity; i++) {
|
|
||||||
password += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
||||||
}
|
|
||||||
return password.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateBase64(bytes = 32) {
|
|
||||||
return Math.random().toString(36).substring(2, 2 + bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified template code
|
|
||||||
${simplifiedCode}
|
|
||||||
|
|
||||||
// Execute the template
|
|
||||||
const result = generate(${JSON.stringify(schema)});
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Write the wrapper code to a file
|
|
||||||
const wrapperPath = join(tempDir, "wrapper.js");
|
|
||||||
await writeFile(wrapperPath, wrapperCode, "utf8");
|
|
||||||
|
|
||||||
// Execute the code using Node.js
|
|
||||||
const output = execSync(`node ${wrapperPath}`, {
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse the output as JSON
|
|
||||||
return JSON.parse(output);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in fallback template execution:", error);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to execute template: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,22 +5,24 @@ import { join } from "node:path";
|
|||||||
import type { Domain } from "@dokploy/server/services/domain";
|
import type { Domain } from "@dokploy/server/services/domain";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { templateConfig } from "../config";
|
import { templateConfig } from "../config";
|
||||||
import { executeTemplateCode, fetchTemplateFiles } from "./github";
|
import { fetchTemplateFiles } from "./github";
|
||||||
|
|
||||||
export interface Schema {
|
export interface Schema {
|
||||||
serverIp: string;
|
serverIp: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DomainSchema = Pick<Domain, "host" | "port" | "serviceName">;
|
export type DomainSchema = Pick<Domain, "host" | "port" | "serviceName"> & {
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
envs?: string[];
|
envs: string[];
|
||||||
mounts?: {
|
mounts: Array<{
|
||||||
filePath: string;
|
filePath: string;
|
||||||
content?: string;
|
content: string;
|
||||||
}[];
|
}>;
|
||||||
domains?: DomainSchema[];
|
domains: DomainSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateRandomDomain = ({
|
export const generateRandomDomain = ({
|
||||||
@@ -33,9 +35,10 @@ export const generateRandomDomain = ({
|
|||||||
return `${projectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.traefik.me`;
|
return `${projectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.traefik.me`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateHash = (projectName: string, quantity = 3): string => {
|
export const generateHash = (length = 8): string => {
|
||||||
const hash = randomBytes(quantity).toString("hex");
|
return randomBytes(Math.ceil(length / 2))
|
||||||
return `${projectName}-${hash}`;
|
.toString("hex")
|
||||||
|
.substring(0, length);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generatePassword = (quantity = 16): string => {
|
export const generatePassword = (quantity = 16): string => {
|
||||||
@@ -74,22 +77,18 @@ async function isCacheValid(filePath: string): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a template's docker-compose.yml file
|
* Reads a template's docker-compose.yml file
|
||||||
* First tries to read from the local cache, if not found or expired, fetches from GitHub
|
* First tries to fetch from GitHub, falls back to local cache if fetch fails
|
||||||
*/
|
*/
|
||||||
export const readTemplateComposeFile = async (id: string) => {
|
export const readTemplateComposeFile = async (id: string) => {
|
||||||
const cwd = process.cwd();
|
// First try to fetch from GitHub
|
||||||
const templatePath = join(cwd, ".next", "templates", id);
|
|
||||||
const composeFilePath = join(templatePath, "docker-compose.yml");
|
|
||||||
|
|
||||||
// Check if the file exists in the local cache and is still valid
|
|
||||||
if (await isCacheValid(composeFilePath)) {
|
|
||||||
return await readFile(composeFilePath, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not in cache or expired, fetch from GitHub and cache it
|
|
||||||
try {
|
try {
|
||||||
const { dockerCompose } = await fetchTemplateFiles(id);
|
const { dockerCompose } = await fetchTemplateFiles(id);
|
||||||
|
|
||||||
|
// Cache the file for future use
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const templatePath = join(cwd, ".next", "templates", id);
|
||||||
|
const composeFilePath = join(templatePath, "docker-compose.yml");
|
||||||
|
|
||||||
// Ensure the template directory exists
|
// Ensure the template directory exists
|
||||||
if (!existsSync(templatePath)) {
|
if (!existsSync(templatePath)) {
|
||||||
await mkdir(templatePath, { recursive: true });
|
await mkdir(templatePath, { recursive: true });
|
||||||
@@ -100,16 +99,24 @@ export const readTemplateComposeFile = async (id: string) => {
|
|||||||
|
|
||||||
return dockerCompose;
|
return dockerCompose;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If fetch fails but we have a cached version, use it as fallback
|
console.warn(`Failed to fetch template ${id} from GitHub:`, error);
|
||||||
|
|
||||||
|
// Try to use cached version as fallback
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const composeFilePath = join(
|
||||||
|
cwd,
|
||||||
|
".next",
|
||||||
|
"templates",
|
||||||
|
id,
|
||||||
|
"docker-compose.yml",
|
||||||
|
);
|
||||||
|
|
||||||
if (existsSync(composeFilePath)) {
|
if (existsSync(composeFilePath)) {
|
||||||
console.warn(
|
console.warn(`Using cached version of template ${id}`);
|
||||||
`Using cached version of template ${id} due to fetch error:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return await readFile(composeFilePath, "utf8");
|
return await readFile(composeFilePath, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(`Error fetching template ${id}:`, error);
|
console.error(`Error: Template ${id} not found in GitHub or cache`);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: `Template ${id} not found or could not be fetched`,
|
message: `Template ${id} not found or could not be fetched`,
|
||||||
@@ -118,49 +125,6 @@ export const readTemplateComposeFile = async (id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a template module and returns its generate function
|
* Loads a template module from GitHub or local cache
|
||||||
* First tries to execute from local cache, if not found or expired, fetches from GitHub
|
* First tries to fetch from GitHub, falls back to local cache if fetch fails
|
||||||
*/
|
*/
|
||||||
export const loadTemplateModule = async (id: string) => {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const templatePath = join(cwd, ".next", "templates", id);
|
|
||||||
const indexFilePath = join(templatePath, "index.ts");
|
|
||||||
|
|
||||||
// Check if we have the template cached locally and it's still valid
|
|
||||||
if (await isCacheValid(indexFilePath)) {
|
|
||||||
const indexTs = await readFile(indexFilePath, "utf8");
|
|
||||||
return (schema: Schema) => executeTemplateCode(indexTs, schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not in cache or expired, fetch from GitHub and cache it
|
|
||||||
try {
|
|
||||||
const { indexTs } = await fetchTemplateFiles(id);
|
|
||||||
|
|
||||||
// Ensure the template directory exists
|
|
||||||
if (!existsSync(templatePath)) {
|
|
||||||
await mkdir(templatePath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the file for future use
|
|
||||||
await writeFile(indexFilePath, indexTs, "utf8");
|
|
||||||
|
|
||||||
// Return a function that will execute the template code
|
|
||||||
return (schema: Schema) => executeTemplateCode(indexTs, schema);
|
|
||||||
} catch (error) {
|
|
||||||
// If fetch fails but we have a cached version, use it as fallback
|
|
||||||
if (existsSync(indexFilePath)) {
|
|
||||||
console.warn(
|
|
||||||
`Using cached version of template ${id} due to fetch error:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
const indexTs = await readFile(indexFilePath, "utf8");
|
|
||||||
return (schema: Schema) => executeTemplateCode(indexTs, schema);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`Error loading template module ${id}:`, error);
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: `Template ${id} not found or could not be loaded`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
44
packages/server/src/templates/utils/metadata.yaml
Normal file
44
packages/server/src/templates/utils/metadata.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
metadata:
|
||||||
|
id: plausible
|
||||||
|
name: Plausible Analytics
|
||||||
|
description: Privacy-focused Google Analytics alternative
|
||||||
|
tags:
|
||||||
|
- analytics
|
||||||
|
- privacy
|
||||||
|
- statistics
|
||||||
|
|
||||||
|
variables:
|
||||||
|
db_password: ${password:32}
|
||||||
|
admin_password: ${password:16}
|
||||||
|
secret_key: ${base64:64}
|
||||||
|
main_domain: ${randomDomain}
|
||||||
|
|
||||||
|
config:
|
||||||
|
domains:
|
||||||
|
- serviceName: plausible
|
||||||
|
port: 8000
|
||||||
|
path: /
|
||||||
|
host: plausible-${main_domain}
|
||||||
|
- serviceName: admin
|
||||||
|
port: 8001
|
||||||
|
path: /admin
|
||||||
|
host: admin-${main_domain}
|
||||||
|
|
||||||
|
env:
|
||||||
|
ADMIN_USER_EMAIL: admin@example.com
|
||||||
|
ADMIN_USER_NAME: admin
|
||||||
|
ADMIN_USER_PWD: ${admin_password}
|
||||||
|
SECRET_KEY_BASE: ${secret_key}
|
||||||
|
DB: plausible
|
||||||
|
DB_USER: plausible
|
||||||
|
DB_PASSWORD: ${db_password}
|
||||||
|
CLICKHOUSE_USER: default
|
||||||
|
CLICKHOUSE_PASSWORD: ${password:32}
|
||||||
|
DOMAIN: ${domain:plausible}
|
||||||
|
ADMIN_DOMAIN: ${domain:admin}
|
||||||
|
|
||||||
|
mounts:
|
||||||
|
- filePath: nginx.conf
|
||||||
|
content: |
|
||||||
|
server_name ${domain:plausible};
|
||||||
|
# resto de la configuración...
|
||||||
42
packages/server/src/templates/utils/template.json
Normal file
42
packages/server/src/templates/utils/template.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"id": "plausible",
|
||||||
|
"name": "Plausible Analytics",
|
||||||
|
"version": "latest",
|
||||||
|
"description": "Privacy-focused Google Analytics alternative",
|
||||||
|
"logo": "plausible.svg",
|
||||||
|
"links": {
|
||||||
|
"website": "https://plausible.io",
|
||||||
|
"docs": "https://plausible.io/docs",
|
||||||
|
"github": "https://github.com/plausible/analytics"
|
||||||
|
},
|
||||||
|
"tags": ["analytics", "privacy", "statistics"]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"domains": {
|
||||||
|
"plausible": {
|
||||||
|
"port": 8000,
|
||||||
|
"path": "/",
|
||||||
|
"serviceName": "plausible",
|
||||||
|
"host": "plausible.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"ADMIN_USER_EMAIL": "admin@example.com",
|
||||||
|
"ADMIN_USER_NAME": "admin",
|
||||||
|
"ADMIN_USER_PWD": "plausible",
|
||||||
|
"SECRET_KEY_BASE": "plausible",
|
||||||
|
"DB": "plausible",
|
||||||
|
"DB_USER": "plausible",
|
||||||
|
"DB_PASSWORD": "plausible",
|
||||||
|
"CLICKHOUSE_USER": "default",
|
||||||
|
"CLICKHOUSE_PASSWORD": "default"
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"filePath": "custom.css",
|
||||||
|
"content": "/* Add your custom CSS here */"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
44
packages/server/src/types/template.ts
Normal file
44
packages/server/src/types/template.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export interface TemplateConfig {
|
||||||
|
variables: Record<string, string>;
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
env: Record<string, string>;
|
||||||
|
mounts: Array<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
metadata: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
logo: string;
|
||||||
|
links: {
|
||||||
|
github: string;
|
||||||
|
website?: string;
|
||||||
|
docs?: string;
|
||||||
|
};
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
variables: Record<string, string>;
|
||||||
|
config: {
|
||||||
|
domains: Array<{
|
||||||
|
serviceName: string;
|
||||||
|
port: number;
|
||||||
|
path?: string;
|
||||||
|
host?: string;
|
||||||
|
}>;
|
||||||
|
env: Record<string, string>;
|
||||||
|
mounts?: Array<{
|
||||||
|
filePath: string;
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user