feat: add deployable option to randomize and prevent colission in duplicate templates

This commit is contained in:
Mauricio Siu 2025-02-03 00:57:18 -06:00
parent 97b77e526d
commit 6f2148c060
13 changed files with 4872 additions and 6 deletions

View File

@ -15,6 +15,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { validateAndFormatYAML } from "../../application/advanced/traefik/update-traefik-config";
import { RandomizeCompose } from "./randomize-compose";
import { RandomizeDeployable } from "./randomize-deployable";
interface Props {
composeId: string;
@ -126,6 +127,7 @@ services:
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end">
<RandomizeCompose composeId={composeId} />
<RandomizeDeployable composeId={composeId} />
</div>
<Button
type="submit"

View File

@ -0,0 +1,196 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, Dices } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
composeId: string;
}
const schema = z.object({
deployable: z.boolean().optional(),
});
type Schema = z.infer<typeof schema>;
export const RandomizeDeployable = ({ composeId }: Props) => {
const utils = api.useUtils();
const [compose, setCompose] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, error, isError } =
api.compose.randomizeDeployableCompose.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const form = useForm<Schema>({
defaultValues: {
deployable: false,
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data) {
form.reset({
deployable: data?.deployable || false,
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (formData: Schema) => {
await updateCompose({
composeId,
deployable: formData?.deployable || false,
})
.then(async (data) => {
randomizeCompose();
refetch();
toast.success("Compose updated");
})
.catch(() => {
toast.error("Error randomizing the compose");
});
};
const randomizeCompose = async () => {
await mutateAsync({
composeId,
suffix: data?.appName || "",
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error randomizing the compose");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild onClick={() => randomizeCompose()}>
<Button className="max-lg:w-full" variant="outline">
<Dices className="h-4 w-4" />
Randomize Deployable
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Randomize Deployable (Experimental)</DialogTitle>
<DialogDescription>
Use this in case you want to deploy the same compose file twice.
</DialogDescription>
</DialogHeader>
<div className="text-sm text-muted-foreground flex flex-col gap-2">
<span>
This will randomize the compose file and will add a suffix to the
property to avoid conflicts
</span>
<ul className="list-disc list-inside">
<li>volumes</li>
<li>networks</li>
<li>services</li>
</ul>
<AlertBlock type="info">
When you activate this option, we will include a env
`DOKPLOY_SUFFIX` variable to the compose file so you can use it in
your compose file, also we don't include the{" "}
<code>dokploy-network</code> to any of the services by default.
</AlertBlock>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-add-project"
className="grid w-full gap-4"
>
{isError && (
<div className="flex flex-row gap-4 rounded-lg items-center bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)}
<div className="flex flex-col lg:flex-col gap-4 w-full ">
<div>
<FormField
control={form.control}
name="deployable"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Apply Randomize ({data?.appName})</FormLabel>
<FormDescription>
Apply randomize to the compose file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col lg:flex-row gap-4 w-full items-end justify-end">
<Button
form="hook-form-add-project"
type="submit"
className="lg:w-fit"
>
Save
</Button>
</div>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "deployable" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -449,6 +449,13 @@
"when": 1738522845992,
"tag": "0063_panoramic_dreadnoughts",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1738564387043,
"tag": "0064_previous_agent_brand",
"breakpoints": true
}
]
}

View File

@ -46,6 +46,7 @@ import {
findServerById,
loadServices,
randomizeComposeFile,
randomizeDeployableComposeFile,
removeCompose,
removeComposeDirectory,
removeDeploymentsByComposeId,
@ -216,6 +217,21 @@ export const composeRouter = createTRPCRouter({
}
return await randomizeComposeFile(input.composeId, input.suffix);
}),
randomizeDeployableCompose: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to randomize this compose",
});
}
return await randomizeDeployableComposeFile(
input.composeId,
input.suffix,
);
}),
getConvertedCompose: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {

View File

@ -69,6 +69,7 @@ export const compose = pgTable("compose", {
composePath: text("composePath").notNull().default("./docker-compose.yml"),
suffix: text("suffix").notNull().default(""),
randomize: boolean("randomize").notNull().default(false),
deployable: boolean("deployable").notNull().default(false),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
projectId: text("projectId")
.notNull()

View File

@ -73,6 +73,7 @@ export * from "./utils/builders/utils";
export * from "./utils/cluster/upload";
export * from "./utils/docker/compose";
export * from "./utils/docker/collision";
export * from "./utils/docker/domain";
export * from "./utils/docker/utils";
export * from "./utils/docker/types";

View File

@ -34,6 +34,9 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
await writeDomainsToCompose(compose, domains);
createEnvFile(compose);
await execAsync(
`docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}`,
);
const logContent = `
App Name: ${appName}
Build Compose 🐳
@ -73,6 +76,10 @@ export const buildCompose = async (compose: ComposeNested, logPath: string) => {
},
);
await execAsync(
`docker network connect tes-umami-e842bc $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1`,
);
writeStream.write("Docker Compose Deployed: ✅");
} catch (error) {
writeStream.write(`Error ❌ ${(error as Error).message}`);

View File

@ -0,0 +1,51 @@
import { findComposeById } from "@dokploy/server/services/compose";
import { addAppNameToAllContainerNames } from "./collision/container-name";
import { addAppNameToAllServiceNames } from "./collision/root-network";
import { addSuffixToAllVolumes } from "./compose/volume";
import type { ComposeSpecification } from "./types";
import { dump, load } from "js-yaml";
import { generateRandomHash } from "./compose";
export const addAppNameToPreventCollision = (
composeData: ComposeSpecification,
appName: string,
): ComposeSpecification => {
let updatedComposeData = { ...composeData };
updatedComposeData = addAppNameToAllContainerNames(
updatedComposeData,
appName,
);
updatedComposeData = addAppNameToAllServiceNames(updatedComposeData, appName);
updatedComposeData = addSuffixToAllVolumes(updatedComposeData, appName);
return updatedComposeData;
};
export const randomizeDeployableComposeFile = async (
composeId: string,
suffix?: string,
) => {
const compose = await findComposeById(composeId);
const composeFile = compose.composeFile;
const composeData = load(composeFile) as ComposeSpecification;
const randomSuffix = suffix || compose.appName || generateRandomHash();
const newComposeFile = addAppNameToPreventCollision(
composeData,
randomSuffix,
);
return dump(newComposeFile);
};
export const randomizeDeployableSpecificationFile = (
composeSpec: ComposeSpecification,
suffix?: string,
) => {
if (!suffix) {
return composeSpec;
}
const newComposeFile = addAppNameToPreventCollision(composeSpec, suffix);
return newComposeFile;
};

View File

@ -0,0 +1,26 @@
import type { ComposeSpecification, DefinitionsService } from "../types";
import _ from "lodash";
export const addAppNameToContainerNames = (
services: { [key: string]: DefinitionsService },
appName: string,
): { [key: string]: DefinitionsService } => {
return _.mapValues(services, (service, serviceName) => {
service.container_name = `${appName}-${serviceName}`;
return service;
});
};
export const addAppNameToAllContainerNames = (
composeData: ComposeSpecification,
appName: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (updatedComposeData.services) {
updatedComposeData.services = addAppNameToContainerNames(
updatedComposeData.services,
appName,
);
}
return updatedComposeData;
};

View File

@ -0,0 +1,62 @@
import type { ComposeSpecification, DefinitionsService } from "../types";
import _ from "lodash";
export const addAppNameToRootNetwork = (
composeData: ComposeSpecification,
appName: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
// Initialize networks if it doesn't exist
if (!updatedComposeData.networks) {
updatedComposeData.networks = {};
}
// Add the new network with the app name
updatedComposeData.networks[appName] = {
name: appName,
external: true,
};
return updatedComposeData;
};
export const addAppNameToServiceNetworks = (
services: { [key: string]: DefinitionsService },
appName: string,
): { [key: string]: DefinitionsService } => {
return _.mapValues(services, (service) => {
if (!service.networks) {
service.networks = [appName];
return service;
}
if (Array.isArray(service.networks)) {
if (!service.networks.includes(appName)) {
service.networks.push(appName);
}
} else {
service.networks[appName] = {};
}
return service;
});
};
export const addAppNameToAllServiceNames = (
composeData: ComposeSpecification,
appName: string,
): ComposeSpecification => {
let updatedComposeData = { ...composeData };
updatedComposeData = addAppNameToRootNetwork(updatedComposeData, appName);
if (updatedComposeData.services) {
updatedComposeData.services = addAppNameToServiceNetworks(
updatedComposeData.services,
appName,
);
}
return updatedComposeData;
};

View File

@ -33,6 +33,7 @@ import type {
PropertiesNetworks,
} from "./types";
import { encodeBase64 } from "./utils";
import { randomizeDeployableSpecificationFile } from "./collision";
export const cloneCompose = async (compose: Compose) => {
if (compose.sourceType === "github") {
@ -190,7 +191,13 @@ export const addDomainToCompose = async (
return null;
}
if (compose.randomize) {
if (compose.deployable) {
const randomized = randomizeDeployableSpecificationFile(
result,
compose.suffix || compose.appName,
);
result = randomized;
} else if (compose.randomize) {
const randomized = randomizeSpecificationFile(result, compose.suffix);
result = randomized;
}
@ -240,14 +247,18 @@ export const addDomainToCompose = async (
labels.push(...httpLabels);
}
// Add the dokploy-network to the service
result.services[serviceName].networks = addDokployNetworkToService(
result.services[serviceName].networks,
);
if (!compose.deployable) {
// Add the dokploy-network to the service
result.services[serviceName].networks = addDokployNetworkToService(
result.services[serviceName].networks,
);
}
}
// Add dokploy-network to the root of the compose file
result.networks = addDokployNetworkToRoot(result.networks);
if (!compose.deployable) {
result.networks = addDokployNetworkToRoot(result.networks);
}
return result;
};