Docker compose support (#111)

* feat(WIP): compose implementation

* feat: add volumes, networks, services name hash generate

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

* refactor: update migrations and add badge to recognize compose type

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

* refactor: add docker provider and expose by default as false

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

* feat: add code editor to enviroment variables and swarm settings json

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

* refactor: show alert to redeploy in each card advanced tab

* refactor: add validation to prevent create compose if not have permissions

* chore: add templates section to contributing

* chore: add example contributing
This commit is contained in:
Mauricio Siu
2024-06-02 15:26:28 -06:00
committed by GitHub
parent 1df6db738e
commit 8f9d21c0f8
139 changed files with 16513 additions and 1208 deletions

View File

@@ -0,0 +1,215 @@
import { db } from "@/server/db";
import { type apiCreateCompose, compose } from "@/server/db/schema";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { load } from "js-yaml";
import { findAdmin } from "./admin";
import { createDeploymentCompose, updateDeploymentStatus } from "./deployment";
import { buildCompose } from "@/server/utils/builders/compose";
import { createComposeFile } from "@/server/utils/providers/raw";
import { execAsync } from "@/server/utils/process/execAsync";
import { join } from "node:path";
import { COMPOSE_PATH } from "@/server/constants";
import { cloneGithubRepository } from "@/server/utils/providers/github";
import { cloneGitRepository } from "@/server/utils/providers/git";
export type Compose = typeof compose.$inferSelect;
export const createCompose = async (input: typeof apiCreateCompose._type) => {
const newDestination = await db
.insert(compose)
.values({
...input,
composeFile: "",
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting compose",
});
}
return newDestination;
};
export const createComposeByTemplate = async (
input: typeof compose.$inferInsert,
) => {
const newDestination = await db
.insert(compose)
.values({
...input,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting compose",
});
}
return newDestination;
};
export const findComposeById = async (composeId: string) => {
const result = await db.query.compose.findFirst({
where: eq(compose.composeId, composeId),
with: {
project: true,
deployments: true,
mounts: true,
},
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Compose not found",
});
}
return result;
};
export const loadServices = async (composeId: string) => {
const compose = await findComposeById(composeId);
// use js-yaml to parse the docker compose file and then extact the services
const composeFile = compose.composeFile;
const composeData = load(composeFile) as ComposeSpecification;
if (!composeData?.services) {
return ["All Services"];
}
const services = Object.keys(composeData.services);
return [...services, "All Services"];
};
export const updateCompose = async (
composeId: string,
composeData: Partial<Compose>,
) => {
const composeResult = await db
.update(compose)
.set({
...composeData,
})
.where(eq(compose.composeId, composeId))
.returning();
return composeResult[0];
};
export const deployCompose = async ({
composeId,
titleLog = "Manual deployment",
}: {
composeId: string;
titleLog: string;
}) => {
const compose = await findComposeById(composeId);
const admin = await findAdmin();
const deployment = await createDeploymentCompose({
composeId: composeId,
title: titleLog,
});
try {
if (compose.sourceType === "github") {
await cloneGithubRepository(admin, compose, deployment.logPath, true);
} else if (compose.sourceType === "git") {
await cloneGitRepository(compose, deployment.logPath, true);
} else {
await createComposeFile(compose, deployment.logPath);
}
await buildCompose(compose, deployment.logPath);
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
composeStatus: "done",
});
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateCompose(composeId, {
composeStatus: "error",
});
throw error;
}
};
export const rebuildCompose = async ({
composeId,
titleLog = "Rebuild deployment",
}: {
composeId: string;
titleLog: string;
}) => {
const compose = await findComposeById(composeId);
const deployment = await createDeploymentCompose({
composeId: composeId,
title: titleLog,
});
try {
await buildCompose(compose, deployment.logPath);
await updateDeploymentStatus(deployment.deploymentId, "done");
await updateCompose(composeId, {
composeStatus: "done",
});
} catch (error) {
await updateDeploymentStatus(deployment.deploymentId, "error");
await updateCompose(composeId, {
composeStatus: "error",
});
throw error;
}
return true;
};
export const removeCompose = async (compose: Compose) => {
try {
const projectPath = join(COMPOSE_PATH, compose.appName);
if (compose.composeType === "stack") {
await execAsync(`docker stack rm ${compose.appName}`, {
cwd: projectPath,
});
} else {
await execAsync(`docker compose -p ${compose.appName} down`, {
cwd: projectPath,
});
}
} catch (error) {
throw error;
}
return true;
};
export const stopCompose = async (composeId: string) => {
const compose = await findComposeById(composeId);
try {
if (compose.composeType === "docker-compose") {
await execAsync(`docker compose -p ${compose.appName} stop`, {
cwd: join(COMPOSE_PATH, compose.appName),
});
}
await updateCompose(composeId, {
composeStatus: "idle",
});
} catch (error) {
await updateCompose(composeId, {
composeStatus: "error",
});
throw error;
}
return true;
};

View File

@@ -2,12 +2,17 @@ import { existsSync, promises as fsPromises } from "node:fs";
import path from "node:path";
import { LOGS_PATH } from "@/server/constants";
import { db } from "@/server/db";
import { deployments } from "@/server/db/schema";
import {
type apiCreateDeployment,
type apiCreateDeploymentCompose,
deployments,
} from "@/server/db/schema";
import { removeDirectoryIfExistsContent } from "@/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
import { type Application, findApplicationById } from "./application";
import { type Compose, findComposeById } from "./compose";
export type Deployment = typeof deployments.$inferSelect;
type CreateDeploymentInput = Omit<
@@ -31,7 +36,12 @@ export const findDeploymentById = async (applicationId: string) => {
return application;
};
export const createDeployment = async (deployment: CreateDeploymentInput) => {
export const createDeployment = async (
deployment: Omit<
typeof apiCreateDeployment._type,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
try {
const application = await findApplicationById(deployment.applicationId);
@@ -68,6 +78,48 @@ export const createDeployment = async (deployment: CreateDeploymentInput) => {
}
};
export const createDeploymentCompose = async (
deployment: Omit<
typeof apiCreateDeploymentCompose._type,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
try {
const compose = await findComposeById(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");
const deploymentCreate = await db
.insert(deployments)
.values({
composeId: deployment.composeId,
title: deployment.title || "Deployment",
status: "running",
logPath: logFilePath,
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
return deploymentCreate[0];
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
};
export const removeDeployment = async (deploymentId: string) => {
try {
const deployment = await db
@@ -109,6 +161,23 @@ const removeLastTenDeployments = async (applicationId: string) => {
}
};
const removeLastTenComposeDeployments = async (composeId: string) => {
const deploymentList = await db.query.deployments.findMany({
where: eq(deployments.composeId, composeId),
orderBy: desc(deployments.createdAt),
});
if (deploymentList.length > 10) {
const deploymentsToDelete = deploymentList.slice(10);
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (existsSync(logPath)) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
};
export const removeDeployments = async (application: Application) => {
const { appName, applicationId } = application;
const logsPath = path.join(LOGS_PATH, appName);
@@ -116,6 +185,16 @@ export const removeDeployments = async (application: Application) => {
await removeDeploymentsByApplicationId(applicationId);
};
export const removeDeploymentsByComposeId = async (compose: Compose) => {
const { appName } = compose;
const logsPath = path.join(LOGS_PATH, appName);
await removeDirectoryIfExistsContent(logsPath);
await db
.delete(deployments)
.where(eq(deployments.composeId, compose.composeId))
.returning();
};
export const findAllDeploymentsByApplicationId = async (
applicationId: string,
) => {
@@ -126,6 +205,14 @@ export const findAllDeploymentsByApplicationId = async (
return deploymentsList;
};
export const findAllDeploymentsByComposeId = async (composeId: string) => {
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments.composeId, composeId),
orderBy: desc(deployments.createdAt),
});
return deploymentsList;
};
export const updateDeployment = async (
deploymentId: string,
deploymentData: Partial<Deployment>,

View File

@@ -77,8 +77,7 @@ export const getContainersByAppNameMatch = async (appName: string) => {
);
if (stderr) {
console.error(`Error: ${stderr}`);
return;
return [];
}
if (!stdout) return [];

View File

@@ -37,6 +37,9 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
...(input.serviceType === "redis" && {
redisId: serviceId,
}),
...(input.serviceType === "compose" && {
composeId: serviceId,
}),
})
.returning()
.then((value) => value[0]);
@@ -49,6 +52,7 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
}
return value;
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the mount",
@@ -80,12 +84,12 @@ export const findMountById = async (mountId: string) => {
export const updateMount = async (
mountId: string,
applicationData: Partial<Mount>,
mountData: Partial<Mount>,
) => {
const mount = await db
.update(mounts)
.set({
...applicationData,
...mountData,
})
.where(eq(mounts.mountId, mountId))
.returning();

View File

@@ -37,6 +37,7 @@ export const findProjectById = async (projectId: string) => {
mysql: true,
postgres: true,
redis: true,
compose: true,
},
});
if (!project) {
@@ -73,3 +74,13 @@ export const updateProjectById = async (
return result;
};
export const slugifyProjectName = (projectName: string): string => {
return projectName
.toLowerCase()
.replace(/[0-9]/g, "")
.replace(/[^a-z\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
};

View File

@@ -21,7 +21,12 @@ export const getDokployImage = () => {
export const pullLatestRelease = async () => {
try {
await docker.pull(getDokployImage(), {});
const stream = await docker.pull(getDokployImage(), {});
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) =>
err ? reject(err) : resolve(res),
);
});
const newUpdateIsAvailable = await updateIsAvailable();
return newUpdateIsAvailable;
} catch (error) {}