feat: deploy compose on external servers

This commit is contained in:
Mauricio Siu
2024-09-08 22:40:42 -06:00
parent 3d60236b36
commit 0a889c5db1
15 changed files with 274 additions and 81 deletions

View File

@@ -11,7 +11,7 @@ interface Props {
logPath: string | null; logPath: string | null;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
serverId: string; serverId?: string;
} }
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");

View File

@@ -9,10 +9,16 @@ import { useEffect, useRef, useState } from "react";
interface Props { interface Props {
logPath: string | null; logPath: string | null;
serverId?: string;
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
export const ShowDeploymentCompose = ({ logPath, open, onClose }: Props) => { export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
}: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null); const endOfLogsRef = useRef<HTMLDivElement>(null);
@@ -21,7 +27,7 @@ export const ShowDeploymentCompose = ({ logPath, open, onClose }: Props) => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}`; const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => { ws.onmessage = (e) => {

View File

@@ -111,6 +111,7 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
</div> </div>
)} )}
<ShowDeploymentCompose <ShowDeploymentCompose
serverId={data?.serverId || ""}
open={activeLog !== null} open={activeLog !== null}
onClose={() => setActiveLog(null)} onClose={() => setActiveLog(null)}
logPath={activeLog} logPath={activeLog}

View File

@@ -116,6 +116,7 @@ export const ComposeActions = ({ composeId }: Props) => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{data?.server?.name}
</div> </div>
); );
}; };

View File

@@ -173,9 +173,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
{server.name} {server.name}
</SelectItem> </SelectItem>
))} ))}
<SelectLabel> <SelectLabel>Servers ({servers?.length})</SelectLabel>
Registries ({servers?.length})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -22,7 +22,9 @@ import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
@@ -51,6 +53,9 @@ const AddComposeSchema = z.object({
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'", "App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}), }),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().min(1, {
message: "Server is required",
}),
}); });
type AddCompose = z.infer<typeof AddComposeSchema>; type AddCompose = z.infer<typeof AddComposeSchema>;
@@ -63,6 +68,7 @@ interface Props {
export const AddCompose = ({ projectId, projectName }: Props) => { export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const slug = slugify(projectName); const slug = slugify(projectName);
const { data: servers } = api.server.all.useQuery();
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation(); api.compose.create.useMutation();
@@ -72,6 +78,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
description: "", description: "",
composeType: "docker-compose", composeType: "docker-compose",
appName: `${slug}-`, appName: `${slug}-`,
serverId: "",
}, },
resolver: zodResolver(AddComposeSchema), resolver: zodResolver(AddComposeSchema),
}); });
@@ -87,6 +94,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
projectId, projectId,
composeType: data.composeType, composeType: data.composeType,
appName: data.appName, appName: data.appName,
serverId: data.serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Compose Created"); toast.success("Compose Created");
@@ -148,6 +156,37 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
)} )}
/> />
</div> </div>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a Server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="appName" name="appName"

View File

@@ -59,7 +59,6 @@ import { addNewService, checkServiceAccess } from "../services/user";
import { unzipDrop } from "@/server/utils/builders/drop"; import { unzipDrop } from "@/server/utils/builders/drop";
import { uploadFileSchema } from "@/utils/schema"; import { uploadFileSchema } from "@/utils/schema";
import { Queue } from "bullmq";
export const applicationRouter = createTRPCRouter({ export const applicationRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure

View File

@@ -13,7 +13,7 @@ import {
type DeploymentJob, type DeploymentJob,
cleanQueuesByCompose, cleanQueuesByCompose,
} from "@/server/queues/deployments-queue"; } from "@/server/queues/deployments-queue";
import { myQueue } from "@/server/queues/queueSetup"; import { enqueueDeploymentJob, myQueue } from "@/server/queues/queueSetup";
import { createCommand } from "@/server/utils/builders/compose"; import { createCommand } from "@/server/utils/builders/compose";
import { import {
randomizeComposeFile, randomizeComposeFile,
@@ -49,6 +49,7 @@ import { createMount } from "../services/mount";
import { findProjectById } from "../services/project"; import { findProjectById } from "../services/project";
import { addNewService, checkServiceAccess } from "../services/user"; import { addNewService, checkServiceAccess } from "../services/user";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { findApplicationById } from "../services/application";
export const composeRouter = createTRPCRouter({ export const composeRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -162,6 +163,22 @@ export const composeRouter = createTRPCRouter({
deploy: protectedProcedure deploy: protectedProcedure
.input(apiFindCompose) .input(apiFindCompose)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
// const jobData: DeploymentJob = {
// composeId: input.composeId,
// titleLog: "Manual deployment",
// type: "deploy",
// applicationType: "compose",
// descriptionLog: "",
// };
// await myQueue.add(
// "deployments",
// { ...jobData },
// {
// removeOnComplete: true,
// removeOnFail: true,
// },
// );
const compose = await findComposeById(input.composeId);
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
composeId: input.composeId, composeId: input.composeId,
titleLog: "Manual deployment", titleLog: "Manual deployment",
@@ -169,14 +186,10 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose", applicationType: "compose",
descriptionLog: "", descriptionLog: "",
}; };
await myQueue.add( if (!compose.serverId) {
"deployments", } else {
{ ...jobData }, await enqueueDeploymentJob(compose.serverId, jobData);
{ }
removeOnComplete: true,
removeOnFail: true,
},
);
}), }),
redeploy: protectedProcedure redeploy: protectedProcedure
.input(apiFindCompose) .input(apiFindCompose)

View File

@@ -3,23 +3,43 @@ import { COMPOSE_PATH } from "@/server/constants";
import { db } from "@/server/db"; import { db } from "@/server/db";
import { type apiCreateCompose, compose } from "@/server/db/schema"; import { type apiCreateCompose, compose } from "@/server/db/schema";
import { generateAppName } from "@/server/db/schema/utils"; import { generateAppName } from "@/server/db/schema/utils";
import { buildCompose } from "@/server/utils/builders/compose"; import {
buildCompose,
getBuildComposeCommand,
} from "@/server/utils/builders/compose";
import { randomizeSpecificationFile } from "@/server/utils/docker/compose"; import { randomizeSpecificationFile } from "@/server/utils/docker/compose";
import { cloneCompose, loadDockerCompose } from "@/server/utils/docker/domain"; import { cloneCompose, loadDockerCompose } from "@/server/utils/docker/domain";
import { sendBuildErrorNotifications } from "@/server/utils/notifications/build-error"; import { sendBuildErrorNotifications } from "@/server/utils/notifications/build-error";
import { sendBuildSuccessNotifications } from "@/server/utils/notifications/build-success"; import { sendBuildSuccessNotifications } from "@/server/utils/notifications/build-success";
import { execAsync } from "@/server/utils/process/execAsync"; import { execAsync } from "@/server/utils/process/execAsync";
import { cloneBitbucketRepository } from "@/server/utils/providers/bitbucket"; import {
import { cloneGitRepository } from "@/server/utils/providers/git"; cloneBitbucketRepository,
import { cloneGithubRepository } from "@/server/utils/providers/github"; getBitbucketCloneCommand,
import { cloneGitlabRepository } from "@/server/utils/providers/gitlab"; } from "@/server/utils/providers/bitbucket";
import { createComposeFile } from "@/server/utils/providers/raw"; import {
cloneGitRepository,
getCustomGitCloneCommand,
} from "@/server/utils/providers/git";
import {
cloneGithubRepository,
getGithubCloneCommand,
} from "@/server/utils/providers/github";
import {
cloneGitlabRepository,
getGitlabCloneCommand,
} from "@/server/utils/providers/gitlab";
import {
createComposeFile,
getCreateComposeFileCommand,
} from "@/server/utils/providers/raw";
import { generatePassword } from "@/templates/utils"; import { generatePassword } from "@/templates/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getDokployUrl } from "./admin"; import { getDokployUrl } from "./admin";
import { createDeploymentCompose, updateDeploymentStatus } from "./deployment"; import { createDeploymentCompose, updateDeploymentStatus } from "./deployment";
import { validUniqueServerAppName } from "./project"; import { validUniqueServerAppName } from "./project";
import { getBuildCommand } from "@/server/utils/builders";
import { executeCommand } from "@/server/utils/servers/command";
export type Compose = typeof compose.$inferSelect; export type Compose = typeof compose.$inferSelect;
@@ -97,6 +117,7 @@ export const findComposeById = async (composeId: string) => {
github: true, github: true,
gitlab: true, gitlab: true,
bitbucket: true, bitbucket: true,
server: true,
}, },
}); });
if (!result) { if (!result) {
@@ -173,6 +194,40 @@ export const deployCompose = async ({
}); });
try { try {
if (compose.serverId) {
let command = `
set -e
`;
if (compose.sourceType === "github") {
command += await getGithubCloneCommand(
compose,
deployment.logPath,
true,
);
} else if (compose.sourceType === "gitlab") {
command += await getGitlabCloneCommand(
compose,
deployment.logPath,
true,
);
} else if (compose.sourceType === "bitbucket") {
command += await getBitbucketCloneCommand(
compose,
deployment.logPath,
true,
);
} else if (compose.sourceType === "git") {
command += await getCustomGitCloneCommand(
compose,
deployment.logPath,
true,
);
} else if (compose.sourceType === "raw") {
command += getCreateComposeFileCommand(compose);
}
command += getBuildComposeCommand(compose, deployment.logPath);
await executeCommand(compose.serverId, command);
} else {
if (compose.sourceType === "github") { if (compose.sourceType === "github") {
await cloneGithubRepository(compose, deployment.logPath, true); await cloneGithubRepository(compose, deployment.logPath, true);
} else if (compose.sourceType === "gitlab") { } else if (compose.sourceType === "gitlab") {
@@ -184,7 +239,10 @@ export const deployCompose = async ({
} else if (compose.sourceType === "raw") { } else if (compose.sourceType === "raw") {
await createComposeFile(compose, deployment.logPath); await createComposeFile(compose, deployment.logPath);
} }
await buildCompose(compose, deployment.logPath); await buildCompose(compose, deployment.logPath);
}
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, { await updateCompose(composeId, {
composeStatus: "done", composeStatus: "done",

View File

@@ -100,14 +100,27 @@ export const createDeploymentCompose = async (
try { try {
const compose = await findComposeById(deployment.composeId); const compose = await findComposeById(deployment.composeId);
await removeLastTenComposeDeployments(deployment.composeId); // await removeLastTenComposeDeployments(deployment.composeId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${compose.appName}-${formattedDateTime}.log`; const fileName = `${compose.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, compose.appName, fileName); const logFilePath = path.join(LOGS_PATH, compose.appName, fileName);
if (compose.serverId) {
const server = await findServerById(compose.serverId);
const command = `
mkdir -p ${LOGS_PATH}/${compose.appName};
echo "Initializing deployment" >> ${logFilePath};
`;
await executeCommand(server.serverId, command);
} else {
await fsPromises.mkdir(path.join(LOGS_PATH, compose.appName), { await fsPromises.mkdir(path.join(LOGS_PATH, compose.appName), {
recursive: true, recursive: true,
}); });
await fsPromises.writeFile(logFilePath, "Initializing deployment"); await fsPromises.writeFile(logFilePath, "Initializing deployment");
}
const deploymentCreate = await db const deploymentCreate = await db
.insert(deployments) .insert(deployments)
.values({ .values({

View File

@@ -136,6 +136,7 @@ export const apiCreateCompose = createSchema.pick({
projectId: true, projectId: true,
composeType: true, composeType: true,
appName: true, appName: true,
serverId: true,
}); });
export const apiCreateComposeByTemplate = createSchema export const apiCreateComposeByTemplate = createSchema

View File

@@ -3,8 +3,14 @@ import { findServerById, type Server } from "../api/services/server";
import type { DeploymentJob } from "./deployments-queue"; import type { DeploymentJob } from "./deployments-queue";
import { import {
deployApplication, deployApplication,
rebuildApplication,
updateApplicationStatus, updateApplicationStatus,
} from "../api/services/application"; } from "../api/services/application";
import {
updateCompose,
deployCompose,
rebuildCompose,
} from "../api/services/compose";
export const redisConfig: ConnectionOptions = { export const redisConfig: ConnectionOptions = {
host: "31.220.108.27", host: "31.220.108.27",
@@ -54,15 +60,42 @@ async function setupServerQueueAndWorker(server: Server) {
`deployments-${server.serverId}`, `deployments-${server.serverId}`,
async (job: Job<DeploymentJob>) => { async (job: Job<DeploymentJob>) => {
// Ejecuta el trabajo de despliegue // Ejecuta el trabajo de despliegue
try {
if (job.data.applicationType === "application") { if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running"); await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.type === "deploy") { if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({ await deployApplication({
applicationId: job.data.applicationId, applicationId: job.data.applicationId,
titleLog: job.data.titleLog, titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog, descriptionLog: job.data.descriptionLog,
}); });
} }
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
}
} catch (error) {
console.log("Error", error);
} }
}, },
{ {

View File

@@ -65,6 +65,30 @@ Compose Type: ${composeType} ✅`;
} }
}; };
export const getBuildComposeCommand = (
compose: ComposeNested,
logPath: string,
) => {
const { sourceType, appName, mounts, composeType, domains } = compose;
const command = createCommand(compose);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const logContent = `
App Name: ${appName}
Build Compose 🐳
Detected: ${mounts.length} mounts 📂
Command: docker ${command}
Source Type: docker ${sourceType}
Compose Type: ${composeType}`;
const bashCommand = `
echo "${logContent}" >> ${logPath};
cd ${projectPath} || exit 1;
docker ${command.split(" ").join(" ")} >> ${logPath} 2>&1;
echo "Docker Compose Deployed: ✅" >> ${logPath};
`;
return bashCommand;
};
const sanitizeCommand = (command: string) => { const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim(); const sanitizedCommand = command.trim();

View File

@@ -27,6 +27,13 @@ export const createComposeFile = async (compose: Compose, logPath: string) => {
} }
}; };
export const getCreateComposeFileCommand = (compose: Compose) => {
const { appName, composeFile } = compose;
const outputPath = join(COMPOSE_PATH, appName, "code");
const filePath = join(outputPath, "docker-compose.yml");
return `echo "${composeFile}" > ${filePath}`;
};
export const createComposeFileRaw = async (compose: Compose) => { export const createComposeFileRaw = async (compose: Compose) => {
const { appName, composeFile } = compose; const { appName, composeFile } = compose;
const outputPath = join(COMPOSE_PATH, appName, "code"); const outputPath = join(COMPOSE_PATH, appName, "code");