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;
open: boolean;
onClose: () => void;
serverId: string;
serverId?: string;
}
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState("");

View File

@@ -9,10 +9,16 @@ import { useEffect, useRef, useState } from "react";
interface Props {
logPath: string | null;
serverId?: string;
open: boolean;
onClose: () => void;
}
export const ShowDeploymentCompose = ({ logPath, open, onClose }: Props) => {
export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
}: Props) => {
const [data, setData] = useState("");
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 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);
ws.onmessage = (e) => {

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,9 @@ import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} 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 '-'",
}),
description: z.string().optional(),
serverId: z.string().min(1, {
message: "Server is required",
}),
});
type AddCompose = z.infer<typeof AddComposeSchema>;
@@ -63,6 +68,7 @@ interface Props {
export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const slug = slugify(projectName);
const { data: servers } = api.server.all.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
@@ -72,6 +78,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
description: "",
composeType: "docker-compose",
appName: `${slug}-`,
serverId: "",
},
resolver: zodResolver(AddComposeSchema),
});
@@ -87,6 +94,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
projectId,
composeType: data.composeType,
appName: data.appName,
serverId: data.serverId,
})
.then(async () => {
toast.success("Compose Created");
@@ -148,6 +156,37 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
)}
/>
</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
control={form.control}
name="appName"

View File

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

View File

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

View File

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

View File

@@ -100,14 +100,27 @@ export const createDeploymentCompose = async (
try {
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 fileName = `${compose.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, compose.appName, fileName);
await fsPromises.mkdir(path.join(LOGS_PATH, compose.appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment");
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), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment");
}
const deploymentCreate = await db
.insert(deployments)
.values({

View File

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

View File

@@ -32,43 +32,43 @@ export type DeploymentJob = DeployJob;
// export const deploymentWorker = new Worker(
// "deployments",
// async (job: Job<DeploymentJob>) => {
// try {
// if (job.data.applicationType === "application") {
// await updateApplicationStatus(job.data.applicationId, "running");
// 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({
// applicationId: job.data.applicationId,
// titleLog: job.data.titleLog,
// 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);
// try {
// if (job.data.applicationType === "application") {
// await updateApplicationStatus(job.data.applicationId, "running");
// 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({
// applicationId: job.data.applicationId,
// titleLog: job.data.titleLog,
// 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);
// }
// },
// {
// autorun: false,

View File

@@ -3,8 +3,14 @@ import { findServerById, type Server } from "../api/services/server";
import type { DeploymentJob } from "./deployments-queue";
import {
deployApplication,
rebuildApplication,
updateApplicationStatus,
} from "../api/services/application";
import {
updateCompose,
deployCompose,
rebuildCompose,
} from "../api/services/compose";
export const redisConfig: ConnectionOptions = {
host: "31.220.108.27",
@@ -54,15 +60,42 @@ async function setupServerQueueAndWorker(server: Server) {
`deployments-${server.serverId}`,
async (job: Job<DeploymentJob>) => {
// Ejecuta el trabajo de despliegue
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
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({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
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 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) => {
const { appName, composeFile } = compose;
const outputPath = join(COMPOSE_PATH, appName, "code");