Merge branch 'canary' into hehehai/feat-server-custom-name

This commit is contained in:
Mauricio Siu
2024-06-08 14:37:51 -06:00
295 changed files with 43807 additions and 3328 deletions

View File

@@ -20,6 +20,9 @@ import { securityRouter } from "./routers/security";
import { portRouter } from "./routers/port";
import { adminRouter } from "./routers/admin";
import { dockerRouter } from "./routers/docker";
import { composeRouter } from "./routers/compose";
import { registryRouter } from "./routers/registry";
import { clusterRouter } from "./routers/cluster";
/**
* This is the primary router for your server.
*
@@ -47,6 +50,9 @@ export const appRouter = createTRPCRouter({
security: securityRouter,
redirects: redirectsRouter,
port: portRouter,
compose: composeRouter,
registry: registryRouter,
cluster: clusterRouter,
});
// export type definition of API

View File

@@ -152,11 +152,18 @@ export const adminRouter = createTRPCRouter({
installationId: admin.githubInstallationId,
},
});
const branches = await octokit.rest.repos.listBranches({
owner: input.owner,
repo: input.repo,
});
return branches.data;
const branches = (await octokit.paginate(
octokit.rest.repos.listBranches,
{
owner: input.owner,
repo: input.repo,
},
)) as unknown as Awaited<
ReturnType<typeof octokit.rest.repos.listBranches>
>["data"];
return branches;
}),
haveGithubConfigured: protectedProcedure.query(async () => {
const adminResponse = await findAdmin();

View File

@@ -163,6 +163,7 @@ export const applicationRouter = createTRPCRouter({
applicationId: input.applicationId,
titleLog: "Rebuild deployment",
type: "redeploy",
applicationType: "application",
};
await myQueue.add(
"deployments",
@@ -294,6 +295,7 @@ export const applicationRouter = createTRPCRouter({
applicationId: input.applicationId,
titleLog: "Manual deployment",
type: "deploy",
applicationType: "application",
};
await myQueue.add(
"deployments",

View File

@@ -0,0 +1,48 @@
import { docker } from "@/server/constants";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { getPublicIpWithFallback } from "@/server/wss/terminal";
import type { DockerNode } from "../services/cluster";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { execAsync } from "@/server/utils/process/execAsync";
export const clusterRouter = createTRPCRouter({
getNodes: protectedProcedure.query(async () => {
const workers: DockerNode[] = await docker.listNodes();
return workers;
}),
removeWorker: protectedProcedure
.input(
z.object({
nodeId: z.string(),
}),
)
.mutation(async ({ input }) => {
try {
await execAsync(
`docker node update --availability drain ${input.nodeId}`,
);
await execAsync(`docker node rm ${input.nodeId} --force`);
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Error to remove the node",
cause: error,
});
}
}),
addWorker: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Worker
} ${await getPublicIpWithFallback()}:2377`;
}),
addManager: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Manager
} ${await getPublicIpWithFallback()}:2377`;
}),
});

View File

@@ -0,0 +1,278 @@
import {
apiCreateCompose,
apiCreateComposeByTemplate,
apiFindCompose,
apiRandomizeCompose,
apiUpdateCompose,
compose,
} from "@/server/db/schema";
import {
createCompose,
createComposeByTemplate,
findComposeById,
loadServices,
removeCompose,
stopCompose,
updateCompose,
} from "../services/compose";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { addNewService, checkServiceAccess } from "../services/user";
import {
cleanQueuesByCompose,
type DeploymentJob,
} from "@/server/queues/deployments-queue";
import { myQueue } from "@/server/queues/queueSetup";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { nanoid } from "nanoid";
import { removeDeploymentsByComposeId } from "../services/deployment";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import { createCommand } from "@/server/utils/builders/compose";
import { loadTemplateModule, readComposeFile } from "@/templates/utils";
import { findAdmin } from "../services/admin";
import { TRPCError } from "@trpc/server";
import { findProjectById, slugifyProjectName } from "../services/project";
import { createMount } from "../services/mount";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import { templates } from "@/templates/templates";
export const composeRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
const newService = await createCompose(input);
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, newService.composeId);
}
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the compose",
cause: error,
});
}
}),
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "access");
}
return await findComposeById(input.composeId);
}),
update: protectedProcedure
.input(apiUpdateCompose)
.mutation(async ({ input }) => {
return updateCompose(input.composeId, input);
}),
delete: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "delete");
}
const composeResult = await findComposeById(input.composeId);
const result = await db
.delete(compose)
.where(eq(compose.composeId, input.composeId))
.returning();
const cleanupOperations = [
async () => await removeCompose(composeResult),
async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName),
async () => await removeRSAFiles(composeResult.appName),
];
for (const operation of cleanupOperations) {
try {
await operation();
} catch (error) {}
}
return result[0];
}),
cleanQueues: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await cleanQueuesByCompose(input.composeId);
}),
allServices: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
return await loadServices(input.composeId);
}),
randomizeCompose: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input }) => {
return await randomizeComposeFile(input.composeId, input.prefix);
}),
deploy: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Manual deployment",
type: "deploy",
applicationType: "compose",
};
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}),
redeploy: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Rebuild deployment",
type: "redeploy",
applicationType: "compose",
};
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}),
stop: protectedProcedure.input(apiFindCompose).mutation(async ({ input }) => {
await stopCompose(input.composeId);
return true;
}),
getDefaultCommand: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
const compose = await findComposeById(input.composeId);
const command = createCommand(compose);
return `docker ${command}`;
}),
generateSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
try {
await generateSSHKey(compose.appName);
const file = await readRSAFile(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
await removeRSAFiles(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: null,
});
return true;
}),
deployTemplate: protectedProcedure
.input(apiCreateComposeByTemplate)
.mutation(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
const composeFile = await readComposeFile(input.id);
const generate = await loadTemplateModule(input.id as TemplatesKeys);
const admin = await findAdmin();
if (!admin.serverIp) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"You need to have a server IP to deploy this template in order to generate domains",
});
}
const project = await findProjectById(input.projectId);
const projectName = slugifyProjectName(`${project.name}-${input.id}`);
const { envs, mounts } = generate({
serverIp: admin.serverIp,
projectName: projectName,
});
const compose = await createComposeByTemplate({
...input,
composeFile: composeFile,
env: envs.join("\n"),
name: input.id,
sourceType: "raw",
});
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, compose.composeId);
}
if (mounts && mounts?.length > 0) {
for (const mount of mounts) {
await createMount({
mountPath: mount.mountPath,
content: mount.content,
serviceId: compose.composeId,
serviceType: "compose",
type: "file",
});
}
}
return null;
}),
templates: protectedProcedure.query(async () => {
const templatesData = templates.map((t) => ({
name: t.name,
description: t.description,
id: t.id,
links: t.links,
tags: t.tags,
logo: t.logo,
version: t.version,
}));
return templatesData;
}),
});

View File

@@ -1,11 +1,23 @@
import { apiFindAllByApplication } from "@/server/db/schema";
import { findAllDeploymentsByApplicationId } from "../services/deployment";
import {
apiFindAllByApplication,
apiFindAllByCompose,
} from "@/server/db/schema";
import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
} from "../services/deployment";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input }) => {
return await findAllDeploymentsByApplicationId(input.applicationId);
}),
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input }) => {
return await findAllDeploymentsByApplicationId(input.applicationId);
}),
allByCompose: protectedProcedure
.input(apiFindAllByCompose)
.query(async ({ input }) => {
return await findAllDeploymentsByComposeId(input.composeId);
}),
});

View File

@@ -1,5 +1,15 @@
import { apiCreateMount, apiRemoveMount } from "@/server/db/schema";
import { createMount, deleteMount } from "../services/mount";
import {
apiCreateMount,
apiFindOneMount,
apiRemoveMount,
apiUpdateMount,
} from "@/server/db/schema";
import {
createMount,
deleteMount,
findMountById,
updateMount,
} from "../services/mount";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const mountRouter = createTRPCRouter({
@@ -14,4 +24,14 @@ export const mountRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return await deleteMount(input.mountId);
}),
one: protectedProcedure.input(apiFindOneMount).query(async ({ input }) => {
return await findMountById(input.mountId);
}),
update: protectedProcedure
.input(apiUpdateMount)
.mutation(async ({ input }) => {
await updateMount(input.mountId, input);
return true;
}),
});

View File

@@ -22,6 +22,7 @@ import {
} from "../services/user";
import {
applications,
compose,
mariadb,
mongo,
mysql,
@@ -64,6 +65,9 @@ export const projectRouter = createTRPCRouter({
const service = await db.query.projects.findFirst({
where: eq(projects.projectId, input.projectId),
with: {
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
},
applications: {
where: buildServiceFilter(
applications.applicationId,
@@ -135,6 +139,9 @@ export const projectRouter = createTRPCRouter({
redis: {
where: buildServiceFilter(redis.redisId, accesedServices),
},
compose: {
where: buildServiceFilter(compose.composeId, accesedServices),
},
},
orderBy: desc(projects.createdAt),
});
@@ -149,6 +156,7 @@ export const projectRouter = createTRPCRouter({
mysql: true,
postgres: true,
redis: true,
compose: true,
},
orderBy: desc(projects.createdAt),
});

View File

@@ -0,0 +1,88 @@
import {
apiCreateRegistry,
apiEnableSelfHostedRegistry,
apiFindOneRegistry,
apiRemoveRegistry,
apiTestRegistry,
apiUpdateRegistry,
} from "@/server/db/schema";
import {
createRegistry,
findAllRegistry,
findRegistryById,
removeRegistry,
updateRegistry,
} from "../services/registry";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
import { manageRegistry } from "@/server/utils/traefik/registry";
import { initializeRegistry } from "@/server/setup/registry-setup";
import { execAsync } from "@/server/utils/process/execAsync";
export const registryRouter = createTRPCRouter({
create: adminProcedure
.input(apiCreateRegistry)
.mutation(async ({ ctx, input }) => {
return await createRegistry(input);
}),
remove: adminProcedure
.input(apiRemoveRegistry)
.mutation(async ({ ctx, input }) => {
return await removeRegistry(input.registryId);
}),
update: protectedProcedure
.input(apiUpdateRegistry)
.mutation(async ({ input }) => {
const { registryId, ...rest } = input;
const application = await updateRegistry(registryId, {
...rest,
});
if (!application) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Update: Error to update registry",
});
}
return true;
}),
all: protectedProcedure.query(async () => {
return await findAllRegistry();
}),
one: adminProcedure.input(apiFindOneRegistry).query(async ({ input }) => {
return await findRegistryById(input.registryId);
}),
testRegistry: protectedProcedure
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
await execAsync(loginCommand);
return true;
} catch (error) {
console.log("Error Registry:", error);
return false;
}
}),
enableSelfHostedRegistry: adminProcedure
.input(apiEnableSelfHostedRegistry)
.mutation(async ({ input }) => {
const selfHostedRegistry = await createRegistry({
...input,
registryName: "Self Hosted Registry",
registryType: "selfHosted",
registryUrl:
process.env.NODE_ENV === "production"
? input.registryUrl
: "dokploy-registry.docker.localhost",
imagePrefix: null,
});
await manageRegistry(selfHostedRegistry);
await initializeRegistry(input.username, input.password);
return selfHostedRegistry;
}),
});

View File

@@ -1,4 +1,4 @@
import { docker, MAIN_TRAEFIK_PATH } from "@/server/constants";
import { docker, MAIN_TRAEFIK_PATH, MONITORING_PATH } from "@/server/constants";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import {
cleanStoppedContainers,
@@ -40,6 +40,7 @@ import {
readDirectory,
} from "../services/settings";
import { canAccessToTraefikFiles } from "../services/user";
import { recreateDirectory } from "@/server/utils/filesystem/directory";
export const settingsRouter = createTRPCRouter({
reloadServer: adminProcedure.mutation(async () => {
@@ -85,6 +86,10 @@ export const settingsRouter = createTRPCRouter({
await cleanUpSystemPrune();
return true;
}),
cleanMonitoring: adminProcedure.mutation(async () => {
await recreateDirectory(MONITORING_PATH);
return true;
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input, ctx }) => {
@@ -181,7 +186,7 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
checkAndUpdateImage: adminProcedure.query(async () => {
checkAndUpdateImage: adminProcedure.mutation(async () => {
return await pullLatestRelease();
}),
updateServer: adminProcedure.mutation(async () => {
@@ -238,3 +243,4 @@ export const settingsRouter = createTRPCRouter({
return readConfigInPath(input.path);
}),
});
// apt-get install apache2-utils

View File

@@ -73,6 +73,7 @@ export const findApplicationById = async (applicationId: string) => {
redirects: true,
security: true,
ports: true,
registry: true,
},
});
if (!application) {

View File

@@ -0,0 +1,41 @@
export interface DockerNode {
ID: string;
Version: {
Index: number;
};
CreatedAt: string;
UpdatedAt: string;
Spec: {
Name: string;
Labels: Record<string, string>;
Role: "worker" | "manager";
Availability: "active" | "pause" | "drain";
};
Description: {
Hostname: string;
Platform: {
Architecture: string;
OS: string;
};
Resources: {
NanoCPUs: number;
MemoryBytes: number;
};
Engine: {
EngineVersion: string;
Plugins: Array<{
Type: string;
Name: string;
}>;
};
};
Status: {
State: "unknown" | "down" | "ready" | "disconnected";
Message: string;
Addr: string;
};
ManagerStatus?: {
Leader: boolean;
Addr: string;
};
}

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

@@ -46,6 +46,7 @@ export const findProjectById = async (projectId: string) => {
mysql: true,
postgres: true,
redis: true,
compose: true,
},
});
if (!project) {
@@ -120,3 +121,12 @@ export const validUniqueServerAppName = async (appName: string) => {
return nonEmptyProjects.length === 0;
};
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

@@ -0,0 +1,122 @@
import { type apiCreateRegistry, registry } from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import { db } from "@/server/db";
import { eq } from "drizzle-orm";
import { findAdmin } from "./admin";
import {
manageRegistry,
removeSelfHostedRegistry,
} from "@/server/utils/traefik/registry";
import { removeService } from "@/server/utils/docker/utils";
import { initializeRegistry } from "@/server/setup/registry-setup";
import { execAsync } from "@/server/utils/process/execAsync";
export type Registry = typeof registry.$inferSelect;
export const createRegistry = async (input: typeof apiCreateRegistry._type) => {
const admin = await findAdmin();
return await db.transaction(async (tx) => {
const newRegistry = await tx
.insert(registry)
.values({
...input,
adminId: admin.adminId,
})
.returning()
.then((value) => value[0]);
if (!newRegistry) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting registry",
});
}
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
await execAsync(loginCommand);
return newRegistry;
});
};
export const removeRegistry = async (registryId: string) => {
try {
const response = await db
.delete(registry)
.where(eq(registry.registryId, registryId))
.returning()
.then((res) => res[0]);
if (!response) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
if (response.registryType === "selfHosted") {
await removeSelfHostedRegistry();
await removeService("dokploy-registry");
}
await execAsync(`docker logout ${response.registryUrl}`);
return response;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to remove this registry",
cause: error,
});
}
};
export const updateRegistry = async (
registryId: string,
registryData: Partial<Registry>,
) => {
try {
const response = await db
.update(registry)
.set({
...registryData,
})
.where(eq(registry.registryId, registryId))
.returning()
.then((res) => res[0]);
if (response?.registryType === "selfHosted") {
await manageRegistry(response);
await initializeRegistry(response.username, response.password);
}
return response;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to update this registry",
});
}
};
export const findRegistryById = async (registryId: string) => {
const registryResponse = await db.query.registry.findFirst({
where: eq(registry.registryId, registryId),
columns: {
password: false,
},
});
if (!registryResponse) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
return registryResponse;
};
export const findAllRegistry = async () => {
const registryResponse = await db.query.registry.findMany();
return registryResponse;
};

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) {}

View File

@@ -9,7 +9,9 @@ export const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
export const DYNAMIC_TRAEFIK_PATH = `${BASE_PATH}/traefik/dynamic`;
export const LOGS_PATH = `${BASE_PATH}/logs`;
export const APPLICATIONS_PATH = `${BASE_PATH}/applications`;
export const COMPOSE_PATH = `${BASE_PATH}/compose`;
export const SSH_PATH = `${BASE_PATH}/ssh`;
export const CERTIFICATES_PATH = `${DYNAMIC_TRAEFIK_PATH}/certificates`;
export const REGISTRY_PATH = `${DYNAMIC_TRAEFIK_PATH}/registry`;
export const MONITORING_PATH = `${BASE_PATH}/monitoring`;
export const docker = new Docker();

View File

@@ -1,13 +1,14 @@
import type { Config } from "drizzle-kit";
import { defineConfig } from "drizzle-kit";
console.log("> Generating PG Schema:", process.env.DATABASE_URL);
export default {
export default defineConfig({
schema: "./server/db/schema/index.ts",
driver: "pg",
dialect: "postgresql",
dbCredentials: {
connectionString: process.env.DATABASE_URL || "",
url: process.env.DATABASE_URL || "",
},
verbose: true,
strict: true,
out: "drizzle",
} satisfies Config;
migrations: {
table: "migrations",
schema: "public",
},
});

View File

@@ -6,6 +6,7 @@ import { users } from "./user";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { certificateType } from "./shared";
import { registry } from "./registry";
export const admins = pgTable("admin", {
adminId: text("adminId")
@@ -39,6 +40,7 @@ export const adminsRelations = relations(admins, ({ one, many }) => ({
references: [auth.id],
}),
users: many(users),
registry: many(registry),
}));
const createSchema = createInsertSchema(admins, {

View File

@@ -10,8 +10,16 @@ import { projects } from "./project";
import { security } from "./security";
import { applicationStatus } from "./shared";
import { ports } from "./port";
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
json,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { generateAppName } from "./utils";
import { registry } from "./registry";
export const sourceType = pgEnum("sourceType", ["docker", "git", "github"]);
@@ -22,6 +30,65 @@ export const buildType = pgEnum("buildType", [
"nixpacks",
]);
// TODO: refactor this types
interface HealthCheckSwarm {
Test?: string[] | undefined;
Interval?: number | undefined;
Timeout?: number | undefined;
StartPeriod?: number | undefined;
Retries?: number | undefined;
}
interface RestartPolicySwarm {
Condition?: string | undefined;
Delay?: number | undefined;
MaxAttempts?: number | undefined;
Window?: number | undefined;
}
interface PlacementSwarm {
Constraints?: string[] | undefined;
Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
MaxReplicas?: number | undefined;
Platforms?:
| Array<{
Architecture: string;
OS: string;
}>
| undefined;
}
interface UpdateConfigSwarm {
Parallelism: number;
Delay?: number | undefined;
FailureAction?: string | undefined;
Monitor?: number | undefined;
MaxFailureRatio?: number | undefined;
Order: string;
}
interface ServiceModeSwarm {
Replicated?: { Replicas?: number | undefined } | undefined;
Global?: {} | undefined;
ReplicatedJob?:
| {
MaxConcurrent?: number | undefined;
TotalCompletions?: number | undefined;
}
| undefined;
GlobalJob?: {} | undefined;
}
interface NetworkSwarm {
Target?: string | undefined;
Aliases?: string[] | undefined;
DriverOpts?: { [key: string]: string } | undefined;
}
interface LabelsSwarm {
[name: string]: string;
}
export const applications = pgTable("application", {
applicationId: text("applicationId")
.notNull()
@@ -60,6 +127,17 @@ export const applications = pgTable("application", {
customGitBuildPath: text("customGitBuildPath"),
customGitSSHKey: text("customGitSSHKey"),
dockerfile: text("dockerfile"),
// Docker swarm json
healthCheckSwarm: json("healthCheckSwarm").$type<HealthCheckSwarm>(),
restartPolicySwarm: json("restartPolicySwarm").$type<RestartPolicySwarm>(),
placementSwarm: json("placementSwarm").$type<PlacementSwarm>(),
updateConfigSwarm: json("updateConfigSwarm").$type<UpdateConfigSwarm>(),
rollbackConfigSwarm: json("rollbackConfigSwarm").$type<UpdateConfigSwarm>(),
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
//
replicas: integer("replicas").default(1).notNull(),
applicationStatus: applicationStatus("applicationStatus")
.notNull()
.default("idle"),
@@ -67,6 +145,9 @@ export const applications = pgTable("application", {
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
registryId: text("registryId").references(() => registry.registryId, {
onDelete: "set null",
}),
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
@@ -85,9 +166,101 @@ export const applicationsRelations = relations(
redirects: many(redirects),
security: many(security),
ports: many(ports),
registry: one(registry, {
fields: [applications.registryId],
references: [registry.registryId],
}),
}),
);
const HealthCheckSwarmSchema = z
.object({
Test: z.array(z.string()).optional(),
Interval: z.number().optional(),
Timeout: z.number().optional(),
StartPeriod: z.number().optional(),
Retries: z.number().optional(),
})
.strict();
const RestartPolicySwarmSchema = z
.object({
Condition: z.string().optional(),
Delay: z.number().optional(),
MaxAttempts: z.number().optional(),
Window: z.number().optional(),
})
.strict();
const PreferenceSchema = z
.object({
Spread: z.object({
SpreadDescriptor: z.string(),
}),
})
.strict();
const PlatformSchema = z
.object({
Architecture: z.string(),
OS: z.string(),
})
.strict();
const PlacementSwarmSchema = z
.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
})
.strict();
const UpdateConfigSwarmSchema = z
.object({
Parallelism: z.number(),
Delay: z.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.number().optional(),
MaxFailureRatio: z.number().optional(),
Order: z.string(),
})
.strict();
const ReplicatedSchema = z
.object({
Replicas: z.number().optional(),
})
.strict();
const ReplicatedJobSchema = z
.object({
MaxConcurrent: z.number().optional(),
TotalCompletions: z.number().optional(),
})
.strict();
const ServiceModeSwarmSchema = z
.object({
Replicated: ReplicatedSchema.optional(),
Global: z.object({}).optional(),
ReplicatedJob: ReplicatedJobSchema.optional(),
GlobalJob: z.object({}).optional(),
})
.strict();
const NetworkSwarmSchema = z.array(
z
.object({
Target: z.string().optional(),
Aliases: z.array(z.string()).optional(),
DriverOpts: z.object({}).optional(),
})
.strict(),
);
const LabelsSwarmSchema = z.record(z.string());
const createSchema = createInsertSchema(applications, {
appName: z.string(),
createdAt: z.string(),
@@ -124,6 +297,14 @@ const createSchema = createInsertSchema(applications, {
"nixpacks",
]),
owner: z.string(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
placementSwarm: PlacementSwarmSchema.nullable(),
updateConfigSwarm: UpdateConfigSwarmSchema.nullable(),
rollbackConfigSwarm: UpdateConfigSwarmSchema.nullable(),
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
});
export const apiCreateApplication = createSchema.pick({

109
server/db/schema/compose.ts Normal file
View File

@@ -0,0 +1,109 @@
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { nanoid } from "nanoid";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { projects } from "./project";
import { relations } from "drizzle-orm";
import { deployments } from "./deployment";
import { generateAppName } from "./utils";
import { applicationStatus } from "./shared";
import { mounts } from "./mount";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
"github",
"raw",
]);
export const composeType = pgEnum("composeType", ["docker-compose", "stack"]);
export const compose = pgTable("compose", {
composeId: text("composeId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("compose")),
description: text("description"),
env: text("env"),
composeFile: text("composeFile").notNull().default(""),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceTypeCompose("sourceType").notNull().default("github"),
composeType: composeType("composeType").notNull().default("docker-compose"),
// Github
repository: text("repository"),
owner: text("owner"),
branch: text("branch"),
autoDeploy: boolean("autoDeploy"),
// Git
customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"),
customGitSSHKey: text("customGitSSHKey"),
//
command: text("command").notNull().default(""),
//
composePath: text("composePath").notNull().default("./docker-compose.yml"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const composeRelations = relations(compose, ({ one, many }) => ({
project: one(projects, {
fields: [compose.projectId],
references: [projects.projectId],
}),
deployments: many(deployments),
mounts: many(mounts),
}));
const createSchema = createInsertSchema(compose, {
name: z.string().min(1),
description: z.string(),
env: z.string().optional(),
composeFile: z.string().min(1),
projectId: z.string(),
command: z.string().optional(),
composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(),
});
export const apiCreateCompose = createSchema.pick({
name: true,
description: true,
projectId: true,
composeType: true,
});
export const apiCreateComposeByTemplate = createSchema
.pick({
projectId: true,
})
.extend({
id: z.string().min(1),
});
export const apiFindCompose = z.object({
composeId: z.string().min(1),
});
export const apiUpdateCompose = createSchema.partial().extend({
composeId: z.string(),
composeFile: z.string().optional(),
command: z.string().optional(),
});
export const apiRandomizeCompose = createSchema
.pick({
composeId: true,
})
.extend({
prefix: z.string().optional(),
composeId: z.string().min(1),
});

View File

@@ -4,6 +4,7 @@ import { nanoid } from "nanoid";
import { applications } from "./application";
import { createInsertSchema } from "drizzle-zod";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { compose } from "./compose";
export const deploymentStatus = pgEnum("deploymentStatus", [
"running",
@@ -19,9 +20,13 @@ export const deployments = pgTable("deployment", {
title: text("title").notNull(),
status: deploymentStatus("status").default("running"),
logPath: text("logPath").notNull(),
applicationId: text("applicationId")
.notNull()
.references(() => applications.applicationId, { onDelete: "cascade" }),
applicationId: text("applicationId").references(
() => applications.applicationId,
{ onDelete: "cascade" },
),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
@@ -32,13 +37,18 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.applicationId],
references: [applications.applicationId],
}),
compose: one(compose, {
fields: [deployments.composeId],
references: [compose.composeId],
}),
}));
const schema = createInsertSchema(deployments, {
title: z.string().min(1),
status: z.string().default("running"),
logPath: z.string().min(1),
applicationId: z.string().min(1),
applicationId: z.string(),
composeId: z.string(),
});
export const apiCreateDeployment = schema
@@ -48,10 +58,35 @@ export const apiCreateDeployment = schema
logPath: true,
applicationId: true,
})
.required();
.extend({
applicationId: z.string().min(1),
});
export const apiCreateDeploymentCompose = schema
.pick({
title: true,
status: true,
logPath: true,
composeId: true,
})
.extend({
composeId: z.string().min(1),
});
export const apiFindAllByApplication = schema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
})
.required();
export const apiFindAllByCompose = schema
.pick({
composeId: true,
})
.extend({
composeId: z.string().min(1),
})
.required();

View File

@@ -1,6 +1,5 @@
export * from "./application";
export * from "./postgres";
export * from "./user";
export * from "./admin";
export * from "./auth";
@@ -20,3 +19,5 @@ export * from "./security";
export * from "./port";
export * from "./redis";
export * from "./shared";
export * from "./compose";
export * from "./registry";

View File

@@ -9,6 +9,7 @@ import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { redis } from "./redis";
import { compose } from "./compose";
export const serviceType = pgEnum("serviceType", [
"application",
@@ -17,6 +18,7 @@ export const serviceType = pgEnum("serviceType", [
"mariadb",
"mongo",
"redis",
"compose",
]);
export const mountType = pgEnum("mountType", ["bind", "volume", "file"]);
@@ -51,6 +53,9 @@ export const mounts = pgTable("mount", {
redisId: text("redisId").references(() => redis.redisId, {
onDelete: "cascade",
}),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
});
export const MountssRelations = relations(mounts, ({ one }) => ({
@@ -78,6 +83,10 @@ export const MountssRelations = relations(mounts, ({ one }) => ({
fields: [mounts.redisId],
references: [redis.redisId],
}),
compose: one(compose, {
fields: [mounts.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(mounts, {
@@ -89,7 +98,15 @@ const createSchema = createInsertSchema(mounts, {
mountPath: z.string().min(1),
mountId: z.string().optional(),
serviceType: z
.enum(["application", "postgres", "mysql", "mariadb", "mongo", "redis"])
.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
])
.default("application"),
});
@@ -134,3 +151,7 @@ export const apiFindMountByApplicationId = createSchema
serviceType: true,
})
.required();
export const apiUpdateMount = createSchema.partial().extend({
mountId: z.string().min(1),
});

View File

@@ -11,6 +11,7 @@ import { applications } from "./application";
import { mongo } from "./mongo";
import { redis } from "./redis";
import { admins } from "./admin";
import { compose } from "./compose";
export const projects = pgTable("project", {
projectId: text("projectId")
@@ -34,6 +35,7 @@ export const projectRelations = relations(projects, ({ many, one }) => ({
applications: many(applications),
mongo: many(mongo),
redis: many(redis),
compose: many(compose),
admin: one(admins, {
fields: [projects.adminId],
references: [admins.adminId],

View File

@@ -0,0 +1,98 @@
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { relations, sql } from "drizzle-orm";
import { boolean, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { auth } from "./auth";
import { admins } from "./admin";
import { z } from "zod";
import { applications } from "./application";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const registryType = pgEnum("RegistryType", ["selfHosted", "cloud"]);
export const registry = pgTable("registry", {
registryId: text("registryId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
registryName: text("registryName").notNull(),
imagePrefix: text("imagePrefix"),
username: text("username").notNull(),
password: text("password").notNull(),
registryUrl: text("registryUrl").notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
registryType: registryType("selfHosted").notNull().default("cloud"),
adminId: text("adminId")
.notNull()
.references(() => admins.adminId, { onDelete: "cascade" }),
});
export const registryRelations = relations(registry, ({ one, many }) => ({
admin: one(admins, {
fields: [registry.adminId],
references: [admins.adminId],
}),
applications: many(applications),
}));
const createSchema = createInsertSchema(registry, {
registryName: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
registryUrl: z.string().min(1),
adminId: z.string().min(1),
registryId: z.string().min(1),
registryType: z.enum(["selfHosted", "cloud"]),
imagePrefix: z.string().nullable().optional(),
});
export const apiCreateRegistry = createSchema
.pick({})
.extend({
registryName: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
registryUrl: z.string(),
registryType: z.enum(["selfHosted", "cloud"]),
imagePrefix: z.string().nullable().optional(),
})
.required();
export const apiTestRegistry = createSchema.pick({}).extend({
registryName: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
registryUrl: z.string(),
registryType: z.enum(["selfHosted", "cloud"]),
imagePrefix: z.string().nullable().optional(),
});
export const apiRemoveRegistry = createSchema
.pick({
registryId: true,
})
.required();
export const apiFindOneRegistry = createSchema
.pick({
registryId: true,
})
.required();
export const apiUpdateRegistry = createSchema.partial().extend({
registryId: z.string().min(1),
});
export const apiEnableSelfHostedRegistry = createSchema
.pick({
registryUrl: true,
username: true,
password: true,
})
.required();

View File

@@ -4,12 +4,21 @@ import {
rebuildApplication,
} from "../api/services/application";
import { myQueue, redisConfig } from "./queueSetup";
import { deployCompose, rebuildCompose } from "../api/services/compose";
interface DeployJob {
applicationId: string;
titleLog: string;
type: "deploy" | "redeploy";
}
type DeployJob =
| {
applicationId: string;
titleLog: string;
type: "deploy" | "redeploy";
applicationType: "application";
}
| {
composeId: string;
titleLog: string;
type: "deploy" | "redeploy";
applicationType: "compose";
};
export type DeploymentJob = DeployJob;
@@ -17,16 +26,30 @@ export const deploymentWorker = new Worker(
"deployments",
async (job: Job<DeploymentJob>) => {
try {
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
});
if (job.data.applicationType === "application") {
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
});
}
} else if (job.data.applicationType === "compose") {
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
});
}
}
} catch (error) {
console.log("Error", error);
@@ -42,9 +65,20 @@ export const cleanQueuesByApplication = async (applicationId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
for (const job of jobs) {
if (job.data.applicationId === applicationId) {
if (job?.data?.applicationId === applicationId) {
await job.remove();
console.log(`Removed job ${job.id} for application ${applicationId}`);
}
}
};
export const cleanQueuesByCompose = async (composeId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
for (const job of jobs) {
if (job?.data?.composeId === composeId) {
await job.remove();
console.log(`Removed job ${job.id} for compose ${composeId}`);
}
}
};

View File

@@ -10,7 +10,7 @@ import {
import { setupDeploymentLogsWebSocketServer } from "./wss/listen-deployment";
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
import { setupDirectories } from "./setup/config-paths";
import { initializeNetwork, initializeSwarm } from "./setup/setup";
import { initializeNetwork } from "./setup/setup";
import {
createDefaultMiddlewares,
createDefaultServerTraefikConfig,
@@ -44,7 +44,6 @@ void app.prepare().then(async () => {
if (process.env.NODE_ENV === "production") {
setupDirectories();
createDefaultMiddlewares();
await initializeSwarm();
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();

View File

@@ -33,7 +33,9 @@ export const initializeRedis = async () => {
Ports: [
{
TargetPort: 6379,
PublishedPort: 6379,
...(process.env.NODE_ENV === "development"
? { PublishedPort: 6379 }
: {}),
Protocol: "tcp",
},
],

View File

@@ -0,0 +1,89 @@
import type { CreateServiceOptions } from "dockerode";
import { docker, REGISTRY_PATH } from "../constants";
import { pullImage } from "../utils/docker/utils";
import { execAsync } from "../utils/process/execAsync";
import { generateRandomPassword } from "../auth/random-password";
export const initializeRegistry = async (
username: string,
password: string,
) => {
const imageName = "registry:2.8.3";
const containerName = "dokploy-registry";
await generateRegistryPassword(username, password);
const randomPass = await generateRandomPassword();
const settings: CreateServiceOptions = {
Name: containerName,
TaskTemplate: {
ContainerSpec: {
Image: imageName,
Env: [
"REGISTRY_STORAGE_DELETE_ENABLED=true",
"REGISTRY_AUTH=htpasswd",
"REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm",
"REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd",
`REGISTRY_HTTP_SECRET=${randomPass.hashedPassword}`,
],
Mounts: [
{
Type: "bind",
Source: `${REGISTRY_PATH}/htpasswd`,
Target: "/auth/htpasswd",
ReadOnly: true,
},
{
Type: "volume",
Source: "registry-data",
Target: "/var/lib/registry",
ReadOnly: false,
},
],
},
Networks: [{ Target: "dokploy-network" }],
RestartPolicy: {
Condition: "on-failure",
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
EndpointSpec: {
Ports: [
{
TargetPort: 5000,
PublishedPort: 5000,
Protocol: "tcp",
PublishMode: "host",
},
],
},
};
try {
await pullImage(imageName);
const service = docker.getService(containerName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
});
console.log("Registry Started ✅");
} catch (error) {
await docker.createService(settings);
console.log("Registry Not Found: Starting ✅");
}
};
const generateRegistryPassword = async (username: string, password: string) => {
try {
const command = `htpasswd -nbB ${username} "${password}" > ${REGISTRY_PATH}/htpasswd`;
const result = await execAsync(command);
console.log("Password generated ✅");
return result.stdout.trim();
} catch (error) {
console.error("Error generating password:", error);
return null;
}
};

View File

@@ -7,6 +7,10 @@ import type { MainTraefikConfig } from "../utils/traefik/types";
import type { FileConfig } from "../utils/traefik/file-types";
import type { CreateServiceOptions } from "dockerode";
const TRAEFIK_SSL_PORT =
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT ?? "", 10) || 80;
export const initializeTraefik = async () => {
const imageName = "traefik:v2.5";
const containerName = "dokploy-traefik";
@@ -47,12 +51,12 @@ export const initializeTraefik = async () => {
Ports: [
{
TargetPort: 443,
PublishedPort: 443,
PublishedPort: TRAEFIK_SSL_PORT,
PublishMode: "host",
},
{
TargetPort: 80,
PublishedPort: 80,
PublishedPort: TRAEFIK_PORT,
PublishMode: "host",
},
{
@@ -127,11 +131,18 @@ export const createDefaultTraefikConfig = () => {
}
const configObject: MainTraefikConfig = {
providers: {
...(process.env.NODE_ENV === "development" && {
docker: {
defaultRule: "Host(`{{ trimPrefix `/` .Name }}.docker.localhost`)",
},
}),
...(process.env.NODE_ENV === "development"
? {
docker: {
defaultRule:
"Host(`{{ trimPrefix `/` .Name }}.docker.localhost`)",
},
}
: {
docker: {
exposedByDefault: false,
},
}),
file: {
directory: "/etc/dokploy/traefik/dynamic",
watch: true,
@@ -139,21 +150,10 @@ export const createDefaultTraefikConfig = () => {
},
entryPoints: {
web: {
address: ":80",
...(process.env.NODE_ENV === "production" && {
http: {
redirections: {
entryPoint: {
to: "websecure",
scheme: "https",
permanent: true,
},
},
},
}),
address: `:${TRAEFIK_PORT}`,
},
websecure: {
address: ":443",
address: `:${TRAEFIK_SSL_PORT}`,
...(process.env.NODE_ENV === "production" && {
http: {
tls: {

View File

@@ -0,0 +1,122 @@
import {
createWriteStream,
existsSync,
mkdirSync,
writeFileSync,
} from "node:fs";
import type { InferResultType } from "@/server/types/with";
import { spawnAsync } from "../process/spawnAsync";
import { COMPOSE_PATH } from "@/server/constants";
import { dirname, join } from "node:path";
import {
generateFileMountsCompose,
prepareEnvironmentVariables,
} from "../docker/utils";
import boxen from "boxen";
export type ComposeNested = InferResultType<
"compose",
{ project: true; mounts: true }
>;
export const buildCompose = async (compose: ComposeNested, logPath: string) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const { sourceType, appName, mounts, composeType, env, composePath } =
compose;
try {
const command = createCommand(compose);
generateFileMountsCompose(appName, mounts);
createEnvFile(compose);
const logContent = `
App Name: ${appName}
Build Compose 🐳
Detected: ${mounts.length} mounts 📂
Command: docker ${command}
Source Type: docker ${sourceType}
Compose Type: ${composeType}`;
const logBox = boxen(logContent, {
padding: {
left: 1,
right: 1,
bottom: 1,
},
width: 80,
borderStyle: "double",
});
writeStream.write(`\n${logBox}\n`);
const projectPath = join(COMPOSE_PATH, compose.appName);
await spawnAsync(
"docker",
[...command.split(" ")],
(data) => {
if (writeStream.writable) {
writeStream.write(data.toString());
}
},
{
cwd: projectPath,
},
);
writeStream.write("Docker Compose Deployed: ✅");
} catch (error) {
writeStream.write(`ERROR: ${error}: ❌`);
throw error;
} finally {
writeStream.end();
}
};
const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim();
const parts = sanitizedCommand.split(/\s+/);
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
return restCommand.join(" ");
};
export const createCommand = (compose: ComposeNested) => {
const { composeType, appName, sourceType } = compose;
const path =
sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune`;
}
const customCommand = sanitizeCommand(compose.command);
if (customCommand) {
command = `${command} ${customCommand}`;
}
return command;
};
const createEnvFile = (compose: ComposeNested) => {
const { env, composePath, appName } = compose;
const composeFilePath =
join(COMPOSE_PATH, appName, composePath) ||
join(COMPOSE_PATH, appName, "docker-compose.yml");
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker/config.json";
}
const envFileContent = prepareEnvironmentVariables(envContent).join("\n");
if (!existsSync(dirname(envFilePath))) {
mkdirSync(dirname(envFilePath), { recursive: true });
}
writeFileSync(envFilePath, envFileContent);
};

View File

@@ -5,6 +5,7 @@ import type { CreateServiceOptions } from "dockerode";
import {
calculateResources,
generateBindMounts,
generateConfigContainer,
generateFileMounts,
generateVolumeMounts,
prepareEnvironmentVariables,
@@ -13,6 +14,7 @@ import { buildCustomDocker } from "./docker-file";
import { buildHeroku } from "./heroku";
import { buildNixpacks } from "./nixpacks";
import { buildPaketo } from "./paketo";
import { uploadImage } from "../cluster/upload";
// NIXPACKS codeDirectory = where is the path of the code directory
// HEROKU codeDirectory = where is the path of the code directory
@@ -20,7 +22,7 @@ import { buildPaketo } from "./paketo";
// DOCKERFILE codeDirectory = where is the exact path of the (Dockerfile)
export type ApplicationNested = InferResultType<
"applications",
{ mounts: true; security: true; redirects: true; ports: true }
{ mounts: true; security: true; redirects: true; ports: true; registry: true }
>;
export const buildApplication = async (
application: ApplicationNested,
@@ -42,6 +44,10 @@ export const buildApplication = async (
} else if (buildType === "dockerfile") {
await buildCustomDocker(application, writeStream);
}
if (application.registryId) {
await uploadImage(application, writeStream);
}
await mechanizeDockerContainer(application);
writeStream.write("Docker Deployed: ✅");
} catch (error) {
@@ -59,8 +65,6 @@ export const mechanizeDockerContainer = async (
appName,
env,
mounts,
sourceType,
dockerImage,
cpuLimit,
memoryLimit,
memoryReservation,
@@ -75,16 +79,34 @@ export const mechanizeDockerContainer = async (
cpuLimit,
cpuReservation,
});
const volumesMount = generateVolumeMounts(mounts);
const {
HealthCheck,
RestartPolicy,
Placement,
Labels,
Mode,
RollbackConfig,
UpdateConfig,
Networks,
} = generateConfigContainer(application);
const bindsMount = generateBindMounts(mounts);
const filesMount = generateFileMounts(appName, mounts);
const envVariables = prepareEnvironmentVariables(env);
const image = getImageName(application);
const authConfig = getAuthConfig(application);
const settings: CreateServiceOptions = {
authconfig: authConfig,
Name: appName,
TaskTemplate: {
ContainerSpec: {
Image: sourceType === "docker" ? dockerImage! : `${appName}:latest`,
HealthCheck,
Image: image,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
@@ -93,20 +115,17 @@ export const mechanizeDockerContainer = async (
Args: ["-c", command],
}
: {}),
Labels,
},
Networks: [{ Target: "dokploy-network" }],
RestartPolicy: {
Condition: "on-failure",
},
Networks,
RestartPolicy,
Placement,
Resources: {
...resources,
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
Mode,
RollbackConfig,
EndpointSpec: {
Ports: ports.map((port) => ({
Protocol: port.protocol,
@@ -114,10 +133,7 @@ export const mechanizeDockerContainer = async (
PublishedPort: port.publishedPort,
})),
},
UpdateConfig: {
Parallelism: 1,
Order: "start-first",
},
UpdateConfig,
};
try {
@@ -132,7 +148,43 @@ export const mechanizeDockerContainer = async (
},
});
} catch (error) {
console.log(error);
await docker.createService(settings);
}
// await cleanUpUnusedImages();
};
const getImageName = (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry } = application;
if (sourceType === "docker") {
return dockerImage || "ERROR-NO-IMAGE-PROVIDED";
}
const registryUrl = registry?.registryUrl || "";
const imagePrefix = registry?.imagePrefix ? `${registry.imagePrefix}/` : "";
return registry
? `${registryUrl}/${imagePrefix}${appName}`
: `${appName}:latest`;
};
const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType } = application;
if (sourceType === "docker") {
if (username && password) {
return {
password,
username,
serveraddress: "https://index.docker.io/v1/",
};
}
} else if (registry) {
return {
password: registry.password,
username: registry.username,
serveraddress: registry.registryUrl,
};
}
return undefined;
};

View File

@@ -0,0 +1,65 @@
import type { ApplicationNested } from "../builders";
import { spawnAsync } from "../process/spawnAsync";
import type { WriteStream } from "node:fs";
export const uploadImage = async (
application: ApplicationNested,
writeStream: WriteStream,
) => {
const registry = application.registry;
if (!registry) {
throw new Error("Registry not found");
}
const { registryUrl, imagePrefix, registryType } = registry;
const { appName } = application;
const imageName = `${appName}:latest`;
const finalURL =
registryType === "selfHosted"
? process.env.NODE_ENV === "development"
? "localhost:5000"
: registryUrl
: registryUrl;
const registryTag = imagePrefix
? `${finalURL}/${imagePrefix}/${imageName}`
: `${finalURL}/${imageName}`;
try {
console.log(finalURL, registryTag);
writeStream.write(
`📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${registryTag} | ${finalURL}\n`,
);
await spawnAsync(
"docker",
["login", finalURL, "-u", registry.username, "-p", registry.password],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
await spawnAsync("docker", ["tag", imageName, registryTag], (data) => {
if (writeStream.writable) {
writeStream.write(data);
}
});
await spawnAsync("docker", ["push", registryTag], (data) => {
if (writeStream.writable) {
writeStream.write(data);
}
});
} catch (error) {
console.log(error);
throw error;
}
};
// docker:
// endpoint: "unix:///var/run/docker.sock"
// exposedByDefault: false
// swarmMode: true

View File

@@ -0,0 +1,45 @@
import crypto from "node:crypto";
import type { ComposeSpecification } from "./types";
import { findComposeById } from "@/server/api/services/compose";
import { dump, load } from "js-yaml";
import { addPrefixToAllVolumes } from "./compose/volume";
import { addPrefixToAllConfigs } from "./compose/configs";
import { addPrefixToAllNetworks } from "./compose/network";
import { addPrefixToAllSecrets } from "./compose/secrets";
import { addPrefixToAllServiceNames } from "./compose/service";
export const generateRandomHash = (): string => {
return crypto.randomBytes(4).toString("hex");
};
export const randomizeComposeFile = async (
composeId: string,
prefix?: string,
) => {
const compose = await findComposeById(composeId);
const composeFile = compose.composeFile;
const composeData = load(composeFile) as ComposeSpecification;
const randomPrefix = prefix || generateRandomHash();
const newComposeFile = addPrefixToAllProperties(composeData, randomPrefix);
return dump(newComposeFile);
};
export const addPrefixToAllProperties = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
let updatedComposeData = { ...composeData };
updatedComposeData = addPrefixToAllServiceNames(updatedComposeData, prefix);
updatedComposeData = addPrefixToAllVolumes(updatedComposeData, prefix);
updatedComposeData = addPrefixToAllNetworks(updatedComposeData, prefix);
updatedComposeData = addPrefixToAllConfigs(updatedComposeData, prefix);
updatedComposeData = addPrefixToAllSecrets(updatedComposeData, prefix);
return updatedComposeData;
};

View File

@@ -0,0 +1,73 @@
import _ from "lodash";
import type {
ComposeSpecification,
DefinitionsConfig,
DefinitionsService,
} from "../types";
export const addPrefixToConfigsRoot = (
configs: { [key: string]: DefinitionsConfig },
prefix: string,
): { [key: string]: DefinitionsConfig } => {
const newConfigs: { [key: string]: DefinitionsConfig } = {};
_.forEach(configs, (config, configName) => {
const newConfigName = `${configName}-${prefix}`;
newConfigs[newConfigName] = _.cloneDeep(config);
});
return newConfigs;
};
export const addPrefixToConfigsInServices = (
services: { [key: string]: DefinitionsService },
prefix: string,
): { [key: string]: DefinitionsService } => {
const newServices: { [key: string]: DefinitionsService } = {};
_.forEach(services, (serviceConfig, serviceName) => {
const newServiceConfig = _.cloneDeep(serviceConfig);
// Reemplazar nombres de configs en configs
if (_.has(newServiceConfig, "configs")) {
newServiceConfig.configs = _.map(newServiceConfig.configs, (config) => {
if (_.isString(config)) {
return `${config}-${prefix}`;
}
if (_.isObject(config) && config.source) {
return {
...config,
source: `${config.source}-${prefix}`,
};
}
return config;
});
}
newServices[serviceName] = newServiceConfig;
});
return newServices;
};
export const addPrefixToAllConfigs = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (composeData?.configs) {
updatedComposeData.configs = addPrefixToConfigsRoot(
composeData.configs,
prefix,
);
}
if (composeData?.services) {
updatedComposeData.services = addPrefixToConfigsInServices(
composeData.services,
prefix,
);
}
return updatedComposeData;
};

View File

@@ -0,0 +1,72 @@
import _ from "lodash";
import type {
ComposeSpecification,
DefinitionsNetwork,
DefinitionsService,
} from "../types";
export const addPrefixToNetworksRoot = (
networks: { [key: string]: DefinitionsNetwork },
prefix: string,
): { [key: string]: DefinitionsNetwork } => {
return _.mapKeys(networks, (_value, key) => `${key}-${prefix}`);
};
export const addPrefixToServiceNetworks = (
services: { [key: string]: DefinitionsService },
prefix: string,
): { [key: string]: DefinitionsService } => {
return _.mapValues(services, (service) => {
if (service.networks) {
// 1 Case the most common
if (Array.isArray(service.networks)) {
service.networks = service.networks.map(
(network: string) => `${network}-${prefix}`,
);
} else {
// 2 Case
service.networks = _.mapKeys(
service.networks,
(_value, key) => `${key}-${prefix}`,
);
// 3 Case
service.networks = _.mapValues(service.networks, (value) => {
if (value && typeof value === "object") {
return _.mapKeys(value, (_val, innerKey) => {
if (innerKey === "aliases") {
return "aliases";
}
return `${innerKey}-${prefix}`;
});
}
return value;
});
}
}
return service;
});
};
export const addPrefixToAllNetworks = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (updatedComposeData.networks) {
updatedComposeData.networks = addPrefixToNetworksRoot(
updatedComposeData.networks,
prefix,
);
}
if (updatedComposeData.services) {
updatedComposeData.services = addPrefixToServiceNetworks(
updatedComposeData.services,
prefix,
);
}
return updatedComposeData;
};

View File

@@ -0,0 +1,68 @@
import _ from "lodash";
import type { ComposeSpecification, DefinitionsService } from "../types";
export const addPrefixToSecretsRoot = (
secrets: ComposeSpecification["secrets"],
prefix: string,
): ComposeSpecification["secrets"] => {
const newSecrets: ComposeSpecification["secrets"] = {};
_.forEach(secrets, (secretConfig, secretName) => {
const newSecretName = `${secretName}-${prefix}`;
newSecrets[newSecretName] = _.cloneDeep(secretConfig);
});
return newSecrets;
};
export const addPrefixToSecretsInServices = (
services: { [key: string]: DefinitionsService },
prefix: string,
): { [key: string]: DefinitionsService } => {
const newServices: { [key: string]: DefinitionsService } = {};
_.forEach(services, (serviceConfig, serviceName) => {
const newServiceConfig = _.cloneDeep(serviceConfig);
// Replace secret names in secrets
if (_.has(newServiceConfig, "secrets")) {
newServiceConfig.secrets = _.map(newServiceConfig.secrets, (secret) => {
if (_.isString(secret)) {
return `${secret}-${prefix}`;
}
if (_.isObject(secret) && secret.source) {
return {
...secret,
source: `${secret.source}-${prefix}`,
};
}
return secret;
});
}
newServices[serviceName] = newServiceConfig;
});
return newServices;
};
export const addPrefixToAllSecrets = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (composeData?.secrets) {
updatedComposeData.secrets = addPrefixToSecretsRoot(
composeData.secrets,
prefix,
);
}
if (composeData?.services) {
updatedComposeData.services = addPrefixToSecretsInServices(
composeData.services,
prefix,
);
}
return updatedComposeData;
};

View File

@@ -0,0 +1,90 @@
// En la sección depends_on de otros servicios: Para definir dependencias entre servicios.
// En la sección networks de otros servicios: Aunque esto no es común, es posible referenciar servicios en redes personalizadas.
// En la sección volumes_from de otros servicios: Para reutilizar volúmenes definidos por otro servicio.
// En la sección links de otros servicios: Para crear enlaces entre servicios.
// En la sección extends de otros servicios: Para extender la configuración de otro servicio.
import _ from "lodash";
import type { ComposeSpecification, DefinitionsService } from "../types";
type DependsOnObject = NonNullable<
Exclude<DefinitionsService["depends_on"], string[]> extends infer T
? { [K in keyof T]: T[K] }
: never
>;
export const addPrefixToServiceNames = (
services: { [key: string]: DefinitionsService },
prefix: string,
): { [key: string]: DefinitionsService } => {
const newServices: { [key: string]: DefinitionsService } = {};
for (const [serviceName, serviceConfig] of Object.entries(services)) {
const newServiceName = `${serviceName}-${prefix}`;
const newServiceConfig = _.cloneDeep(serviceConfig);
// Reemplazar nombres de servicios en depends_on
if (newServiceConfig.depends_on) {
if (Array.isArray(newServiceConfig.depends_on)) {
newServiceConfig.depends_on = newServiceConfig.depends_on.map(
(dep) => `${dep}-${prefix}`,
);
} else {
const newDependsOn: DependsOnObject = {};
for (const [depName, depConfig] of Object.entries(
newServiceConfig.depends_on,
)) {
newDependsOn[`${depName}-${prefix}`] = depConfig;
}
newServiceConfig.depends_on = newDependsOn;
}
}
// Reemplazar nombre en container_name
if (newServiceConfig.container_name) {
newServiceConfig.container_name = `${newServiceConfig.container_name}-${prefix}`;
}
// Reemplazar nombres de servicios en links
if (newServiceConfig.links) {
newServiceConfig.links = newServiceConfig.links.map(
(link) => `${link}-${prefix}`,
);
}
// Reemplazar nombres de servicios en extends
if (newServiceConfig.extends) {
if (typeof newServiceConfig.extends === "string") {
newServiceConfig.extends = `${newServiceConfig.extends}-${prefix}`;
} else {
newServiceConfig.extends.service = `${newServiceConfig.extends.service}-${prefix}`;
}
}
// Reemplazar nombres de servicios en volumes_from
if (newServiceConfig.volumes_from) {
newServiceConfig.volumes_from = newServiceConfig.volumes_from.map(
(vol) => `${vol}-${prefix}`,
);
}
newServices[newServiceName] = newServiceConfig;
}
return newServices;
};
export const addPrefixToAllServiceNames = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (updatedComposeData.services) {
updatedComposeData.services = addPrefixToServiceNames(
updatedComposeData.services,
prefix,
);
}
return updatedComposeData;
};

View File

@@ -0,0 +1,78 @@
import _ from "lodash";
import type {
ComposeSpecification,
DefinitionsService,
DefinitionsVolume,
} from "../types";
// Función para agregar prefijo a volúmenes
export const addPrefixToVolumesRoot = (
volumes: { [key: string]: DefinitionsVolume },
prefix: string,
): { [key: string]: DefinitionsVolume } => {
return _.mapKeys(volumes, (_value, key) => `${key}-${prefix}`);
};
export const addPrefixToVolumesInServices = (
services: { [key: string]: DefinitionsService },
prefix: string,
): { [key: string]: DefinitionsService } => {
const newServices: { [key: string]: DefinitionsService } = {};
_.forEach(services, (serviceConfig, serviceName) => {
const newServiceConfig = _.cloneDeep(serviceConfig);
// Reemplazar nombres de volúmenes en volumes
if (_.has(newServiceConfig, "volumes")) {
newServiceConfig.volumes = _.map(newServiceConfig.volumes, (volume) => {
if (_.isString(volume)) {
const [volumeName, path] = volume.split(":");
// skip bind mounts and variables (e.g. $PWD)
if (
volumeName?.startsWith(".") ||
volumeName?.startsWith("/") ||
volumeName?.startsWith("$")
) {
return volume;
}
return `${volumeName}-${prefix}:${path}`;
}
if (_.isObject(volume) && volume.type === "volume" && volume.source) {
return {
...volume,
source: `${volume.source}-${prefix}`,
};
}
return volume;
});
}
newServices[serviceName] = newServiceConfig;
});
return newServices;
};
export const addPrefixToAllVolumes = (
composeData: ComposeSpecification,
prefix: string,
): ComposeSpecification => {
const updatedComposeData = { ...composeData };
if (updatedComposeData.volumes) {
updatedComposeData.volumes = addPrefixToVolumesRoot(
updatedComposeData.volumes,
prefix,
);
}
if (updatedComposeData.services) {
updatedComposeData.services = addPrefixToVolumesInServices(
updatedComposeData.services,
prefix,
);
}
return updatedComposeData;
};

View File

@@ -0,0 +1,879 @@
export type DefinitionsInclude =
| string
| {
path?: StringOrList;
env_file?: StringOrList;
project_directory?: string;
};
export type StringOrList = string | ListOfStrings;
export type ListOfStrings = string[];
export type DefinitionsDevelopment = {
watch?: {
ignore?: string[];
path: string;
action: "rebuild" | "sync" | "sync+restart";
target?: string;
[k: string]: unknown;
}[];
[k: string]: unknown;
} & Development;
export type Development = {
watch?: {
ignore?: string[];
path: string;
action: "rebuild" | "sync" | "sync+restart";
target?: string;
[k: string]: unknown;
}[];
[k: string]: unknown;
} | null;
export type DefinitionsDeployment = {
mode?: string;
endpoint_mode?: string;
replicas?: number;
labels?: ListOrDict;
rollback_config?: {
parallelism?: number;
delay?: string;
failure_action?: string;
monitor?: string;
max_failure_ratio?: number;
order?: "start-first" | "stop-first";
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
update_config?: {
parallelism?: number;
delay?: string;
failure_action?: string;
monitor?: string;
max_failure_ratio?: number;
order?: "start-first" | "stop-first";
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
resources?: {
limits?: {
cpus?: number | string;
memory?: string;
pids?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
reservations?: {
cpus?: number | string;
memory?: string;
generic_resources?: DefinitionsGenericResources;
devices?: DefinitionsDevices;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
restart_policy?: {
condition?: string;
delay?: string;
max_attempts?: number;
window?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
placement?: {
constraints?: string[];
preferences?: {
spread?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
max_replicas_per_node?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} & Deployment;
export type ListOrDict =
| {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` ".+".
*/
[k: string]: string | number | boolean | null;
}
| string[];
export type DefinitionsGenericResources = {
discrete_resource_spec?: {
kind?: string;
value?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
export type DefinitionsDevices = {
capabilities?: ListOfStrings;
count?: string | number;
device_ids?: ListOfStrings;
driver?: string;
options?: ListOrDict;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
export type Deployment = {
mode?: string;
endpoint_mode?: string;
replicas?: number;
labels?: ListOrDict;
rollback_config?: {
parallelism?: number;
delay?: string;
failure_action?: string;
monitor?: string;
max_failure_ratio?: number;
order?: "start-first" | "stop-first";
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
update_config?: {
parallelism?: number;
delay?: string;
failure_action?: string;
monitor?: string;
max_failure_ratio?: number;
order?: "start-first" | "stop-first";
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
resources?: {
limits?: {
cpus?: number | string;
memory?: string;
pids?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
reservations?: {
cpus?: number | string;
memory?: string;
generic_resources?: DefinitionsGenericResources;
devices?: DefinitionsDevices;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
restart_policy?: {
condition?: string;
delay?: string;
max_attempts?: number;
window?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
placement?: {
constraints?: string[];
preferences?: {
spread?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
max_replicas_per_node?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} | null;
export type ServiceConfigOrSecret = (
| string
| {
source?: string;
target?: string;
uid?: string;
gid?: string;
mode?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
)[];
export type Command = null | string | string[];
export type EnvFile =
| string
| (
| string
| {
path: string;
required?: boolean;
}
)[];
/**
* This interface was referenced by `PropertiesNetworks`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
export type DefinitionsNetwork = {
name?: string;
driver?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
ipam?: {
driver?: string;
config?: {
subnet?: string;
ip_range?: string;
gateway?: string;
aux_addresses?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
options?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
external?:
| boolean
| {
name?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
internal?: boolean;
enable_ipv6?: boolean;
attachable?: boolean;
labels?: ListOrDict;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} & Network;
export type Network = {
name?: string;
driver?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
ipam?: {
driver?: string;
config?: {
subnet?: string;
ip_range?: string;
gateway?: string;
aux_addresses?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}[];
options?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
external?:
| boolean
| {
name?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
internal?: boolean;
enable_ipv6?: boolean;
attachable?: boolean;
labels?: ListOrDict;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} | null;
/**
* This interface was referenced by `PropertiesVolumes`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
export type DefinitionsVolume = {
name?: string;
driver?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
external?:
| boolean
| {
name?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
labels?: ListOrDict;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} & Volume;
export type Volume = {
name?: string;
driver?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
external?:
| boolean
| {
name?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
labels?: ListOrDict;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} | null;
/**
* The Compose file is a YAML file defining a multi-containers based application.
*/
export interface ComposeSpecification {
/**
* declared for backward compatibility, ignored.
*/
version?: string;
/**
* define the Compose project name, until user defines one explicitly.
*/
name?: string;
/**
* compose sub-projects to be included.
*/
include?: DefinitionsInclude[];
services?: PropertiesServices;
networks?: PropertiesNetworks;
volumes?: PropertiesVolumes;
secrets?: PropertiesSecrets;
configs?: PropertiesConfigs;
/**
* This interface was referenced by `ComposeSpecification`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
export interface PropertiesServices {
[k: string]: DefinitionsService;
}
/**
* This interface was referenced by `PropertiesServices`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
export interface DefinitionsService {
develop?: DefinitionsDevelopment;
deploy?: DefinitionsDeployment;
annotations?: ListOrDict;
attach?: boolean;
build?:
| string
| {
context?: string;
dockerfile?: string;
dockerfile_inline?: string;
entitlements?: string[];
args?: ListOrDict;
ssh?: ListOrDict;
labels?: ListOrDict;
cache_from?: string[];
cache_to?: string[];
no_cache?: boolean;
additional_contexts?: ListOrDict;
network?: string;
pull?: boolean;
target?: string;
shm_size?: number | string;
extra_hosts?: ListOrDict;
isolation?: string;
privileged?: boolean;
secrets?: ServiceConfigOrSecret;
tags?: string[];
ulimits?: Ulimits;
platforms?: string[];
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
blkio_config?: {
device_read_bps?: BlkioLimit[];
device_read_iops?: BlkioLimit[];
device_write_bps?: BlkioLimit[];
device_write_iops?: BlkioLimit[];
weight?: number;
weight_device?: BlkioWeight[];
};
cap_add?: string[];
cap_drop?: string[];
cgroup?: "host" | "private";
cgroup_parent?: string;
command?: Command;
configs?: ServiceConfigOrSecret;
container_name?: string;
cpu_count?: number;
cpu_percent?: number;
cpu_shares?: number | string;
cpu_quota?: number | string;
cpu_period?: number | string;
cpu_rt_period?: number | string;
cpu_rt_runtime?: number | string;
cpus?: number | string;
cpuset?: string;
credential_spec?: {
config?: string;
file?: string;
registry?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
depends_on?:
| ListOfStrings
| {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
[k: string]: {
restart?: boolean;
required?: boolean;
condition:
| "service_started"
| "service_healthy"
| "service_completed_successfully";
};
};
device_cgroup_rules?: ListOfStrings;
devices?: string[];
dns?: StringOrList;
dns_opt?: string[];
dns_search?: StringOrList;
domainname?: string;
entrypoint?: Command;
env_file?: EnvFile;
environment?: ListOrDict;
expose?: (string | number)[];
extends?:
| string
| {
service: string;
file?: string;
};
external_links?: string[];
extra_hosts?: ListOrDict;
group_add?: (string | number)[];
healthcheck?: DefinitionsHealthcheck;
hostname?: string;
image?: string;
init?: boolean;
ipc?: string;
isolation?: string;
labels?: ListOrDict;
links?: string[];
logging?: {
driver?: string;
options?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number | null;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
mac_address?: string;
mem_limit?: number | string;
mem_reservation?: string | number;
mem_swappiness?: number;
memswap_limit?: number | string;
network_mode?: string;
networks?:
| ListOfStrings
| {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
[k: string]: {
aliases?: ListOfStrings;
ipv4_address?: string;
ipv6_address?: string;
link_local_ips?: ListOfStrings;
mac_address?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
priority?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
} | null;
};
oom_kill_disable?: boolean;
oom_score_adj?: number;
pid?: string | null;
pids_limit?: number | string;
platform?: string;
ports?: (
| number
| string
| {
name?: string;
mode?: string;
host_ip?: string;
target?: number;
published?: string | number;
protocol?: string;
app_protocol?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
)[];
privileged?: boolean;
profiles?: ListOfStrings;
pull_policy?: "always" | "never" | "if_not_present" | "build" | "missing";
read_only?: boolean;
restart?: string;
runtime?: string;
scale?: number;
security_opt?: string[];
shm_size?: number | string;
secrets?: ServiceConfigOrSecret;
sysctls?: ListOrDict;
stdin_open?: boolean;
stop_grace_period?: string;
stop_signal?: string;
storage_opt?: {
[k: string]: unknown;
};
tmpfs?: StringOrList;
tty?: boolean;
ulimits?: Ulimits;
user?: string;
uts?: string;
userns_mode?: string;
volumes?: (
| string
| {
type: string;
source?: string;
target?: string;
read_only?: boolean;
consistency?: string;
bind?: {
propagation?: string;
create_host_path?: boolean;
selinux?: "z" | "Z";
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
volume?: {
nocopy?: boolean;
subpath?: string;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
tmpfs?: {
size?: number | string;
mode?: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
)[];
volumes_from?: string[];
working_dir?: string;
/**
* This interface was referenced by `DefinitionsService`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
export interface Ulimits {
/**
* This interface was referenced by `Ulimits`'s JSON-Schema definition
* via the `patternProperty` "^[a-z]+$".
*/
[k: string]:
| number
| {
hard: number;
soft: number;
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
};
}
export interface BlkioLimit {
path?: string;
rate?: number | string;
}
export interface BlkioWeight {
path?: string;
weight?: number;
}
export interface DefinitionsHealthcheck {
disable?: boolean;
interval?: string;
retries?: number;
test?: string | string[];
timeout?: string;
start_period?: string;
start_interval?: string;
/**
* This interface was referenced by `DefinitionsHealthcheck`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
export interface PropertiesNetworks {
[k: string]: DefinitionsNetwork;
}
export interface PropertiesVolumes {
[k: string]: DefinitionsVolume;
}
export interface PropertiesSecrets {
[k: string]: DefinitionsSecret;
}
/**
* This interface was referenced by `PropertiesSecrets`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
export interface DefinitionsSecret {
name?: string;
environment?: string;
file?: string;
external?:
| boolean
| {
name?: string;
[k: string]: unknown;
};
labels?: ListOrDict;
driver?: string;
driver_opts?: {
/**
* This interface was referenced by `undefined`'s JSON-Schema definition
* via the `patternProperty` "^.+$".
*/
[k: string]: string | number;
};
template_driver?: string;
/**
* This interface was referenced by `DefinitionsSecret`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}
export interface PropertiesConfigs {
[k: string]: DefinitionsConfig;
}
/**
* This interface was referenced by `PropertiesConfigs`'s JSON-Schema definition
* via the `patternProperty` "^[a-zA-Z0-9._-]+$".
*/
export interface DefinitionsConfig {
name?: string;
content?: string;
environment?: string;
file?: string;
external?:
| boolean
| {
name?: string;
[k: string]: unknown;
};
labels?: ListOrDict;
template_driver?: string;
/**
* This interface was referenced by `DefinitionsConfig`'s JSON-Schema definition
* via the `patternProperty` "^x-".
*/
[k: string]: unknown;
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { Readable } from "node:stream";
import { APPLICATIONS_PATH, docker } from "@/server/constants";
import { APPLICATIONS_PATH, COMPOSE_PATH, docker } from "@/server/constants";
import type { ContainerInfo, ResourceRequirements } from "dockerode";
import type { ApplicationNested } from "../builders";
import { execAsync } from "../process/execAsync";
@@ -122,10 +122,10 @@ export const cleanUpInactiveContainers = async () => {
for (const container of inactiveContainers) {
await docker.getContainer(container.Id).remove({ force: true });
console.log(`Contenedor eliminado: ${container.Id}`);
console.log(`Cleaning up inactive container: ${container.Id}`);
}
} catch (error) {
console.error("Error al limpiar contenedores inactivos:", error);
console.error("Error cleaning up inactive containers:", error);
throw error;
}
};
@@ -199,6 +199,83 @@ export const calculateResources = ({
};
};
export const generateConfigContainer = (application: ApplicationNested) => {
const {
healthCheckSwarm,
restartPolicySwarm,
placementSwarm,
updateConfigSwarm,
rollbackConfigSwarm,
modeSwarm,
labelsSwarm,
replicas,
mounts,
networkSwarm,
} = application;
const haveMounts = mounts.length > 0;
return {
...(healthCheckSwarm && {
HealthCheck: healthCheckSwarm,
}),
...(restartPolicySwarm
? {
RestartPolicy: restartPolicySwarm,
}
: {
// if no restartPolicySwarm provided use default
RestartPolicy: {
Condition: "on-failure",
},
}),
...(placementSwarm
? {
Placement: placementSwarm,
}
: {
// if app have mounts keep manager as constraint
Placement: {
Constraints: haveMounts ? ["node.role==manager"] : [],
},
}),
...(labelsSwarm && {
Labels: labelsSwarm,
}),
...(modeSwarm
? {
Mode: modeSwarm,
}
: {
// use replicas value if no modeSwarm provided
Mode: {
Replicated: {
Replicas: replicas,
},
},
}),
...(rollbackConfigSwarm && {
RollbackConfig: rollbackConfigSwarm,
}),
...(updateConfigSwarm
? { UpdateConfig: updateConfigSwarm }
: {
// default config if no updateConfigSwarm provided
UpdateConfig: {
Parallelism: 1,
Order: "start-first",
},
}),
...(networkSwarm
? {
Networks: networkSwarm,
}
: {
Networks: [{ Target: "dokploy-network" }],
}),
};
};
export const generateBindMounts = (mounts: ApplicationNested["mounts"]) => {
if (!mounts || mounts.length === 0) {
return [];
@@ -247,6 +324,33 @@ export const generateFileMounts = (
});
};
export const generateFileMountsCompose = (
appName: string,
mounts: ApplicationNested["mounts"],
) => {
if (!mounts || mounts.length === 0) {
return [];
}
return mounts
.filter((mount) => mount.type === "file")
.map((mount) => {
const fileName = path.basename(mount.mountPath);
const directory = path.join(
COMPOSE_PATH,
appName,
path.dirname(mount.mountPath),
);
fs.mkdirSync(directory, { recursive: true });
const filePath = path.join(directory, fileName);
fs.writeFileSync(filePath, mount.content || "");
return {};
});
};
export const getServiceContainer = async (appName: string) => {
try {
const filter = {

View File

@@ -1,7 +1,11 @@
import fs, { promises as fsPromises } from "node:fs";
import path from "node:path";
import type { Application } from "@/server/api/services/application";
import { APPLICATIONS_PATH, MONITORING_PATH } from "@/server/constants";
import {
APPLICATIONS_PATH,
COMPOSE_PATH,
MONITORING_PATH,
} from "@/server/constants";
import { execAsync } from "../process/execAsync";
export const recreateDirectory = async (pathFolder: string): Promise<void> => {
@@ -32,6 +36,16 @@ export const removeDirectoryCode = async (appName: string) => {
}
};
export const removeComposeDirectory = async (appName: string) => {
const directoryPath = path.join(COMPOSE_PATH, appName);
try {
await execAsync(`rm -rf ${directoryPath}`);
} catch (error) {
console.error(`Error to remove ${directoryPath}: ${error}`);
throw error;
}
};
export const removeMonitoringDirectory = async (appName: string) => {
const directoryPath = path.join(MONITORING_PATH, appName);
try {

View File

@@ -1,58 +1,58 @@
import {
type ChildProcess,
type SpawnOptions,
spawn,
type ChildProcess,
type SpawnOptions,
spawn,
} from "node:child_process";
import BufferList from "bl";
export const spawnAsync = (
command: string,
args?: string[] | undefined,
onData?: (data: string) => void, // Callback opcional para manejar datos en tiempo real
options?: SpawnOptions,
command: string,
args?: string[] | undefined,
onData?: (data: string) => void, // Callback opcional para manejar datos en tiempo real
options?: SpawnOptions,
): Promise<BufferList> & { child: ChildProcess } => {
const child = spawn(command, args ?? [], options ?? {});
const stdout = child.stdout ? new BufferList() : new BufferList();
const stderr = child.stderr ? new BufferList() : new BufferList();
const child = spawn(command, args ?? [], options ?? {});
const stdout = child.stdout ? new BufferList() : new BufferList();
const stderr = child.stderr ? new BufferList() : new BufferList();
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout.append(data);
if (onData) {
onData(data.toString());
}
});
}
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr.append(data);
if (onData) {
onData(data.toString());
}
});
}
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout.append(data);
if (onData) {
onData(data.toString());
}
});
}
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr.append(data);
if (onData) {
onData(data.toString());
}
});
}
const promise = new Promise<BufferList>((resolve, reject) => {
child.on("error", reject);
const promise = new Promise<BufferList>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
const err = new Error(`child exited with code ${code}`) as Error & {
code: number;
stderr: BufferList;
stdout: BufferList;
};
err.code = code || -1;
err.stderr = stderr;
err.stdout = stdout;
reject(err);
}
});
}) as Promise<BufferList> & { child: ChildProcess };
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
const err = new Error(`child exited with code ${code}`) as Error & {
code: number;
stderr: BufferList;
stdout: BufferList;
};
err.code = code || -1;
err.stderr = stderr;
err.stdout = stdout;
reject(err);
}
});
}) as Promise<BufferList> & { child: ChildProcess };
promise.child = child;
promise.child = child;
return promise;
return promise;
};

View File

@@ -1,18 +1,22 @@
import { createWriteStream } from "node:fs";
import path from "node:path";
import type { Application } from "@/server/api/services/application";
import { APPLICATIONS_PATH, SSH_PATH } from "@/server/constants";
import path, { join } from "node:path";
import { APPLICATIONS_PATH, COMPOSE_PATH, SSH_PATH } from "@/server/constants";
import { TRPCError } from "@trpc/server";
import { recreateDirectory } from "../filesystem/directory";
import { execAsync } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
export const cloneGitRepository = async (
application: Application,
entity: {
appName: string;
customGitUrl?: string | null;
customGitBranch?: string | null;
customGitSSHKey?: string | null;
},
logPath: string,
isCompose = false,
) => {
const { appName, customGitUrl, customGitBranch, customGitSSHKey } =
application;
const { appName, customGitUrl, customGitBranch, customGitSSHKey } = entity;
if (!customGitUrl || !customGitBranch) {
throw new TRPCError({
@@ -23,7 +27,8 @@ export const cloneGitRepository = async (
const writeStream = createWriteStream(logPath, { flags: "a" });
const keyPath = path.join(SSH_PATH, `${appName}_rsa`);
const outputPath = path.join(APPLICATIONS_PATH, appName);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName);
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
try {

View File

@@ -1,7 +1,6 @@
import { createWriteStream } from "node:fs";
import path from "node:path";
import type { Application } from "@/server/api/services/application";
import { APPLICATIONS_PATH } from "@/server/constants";
import { join } from "node:path";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
@@ -49,25 +48,36 @@ export const haveGithubRequirements = (admin: Admin) => {
);
};
const getErrorCloneRequirements = (application: Application) => {
const getErrorCloneRequirements = (entity: {
repository?: string | null;
owner?: string | null;
branch?: string | null;
}) => {
const reasons: string[] = [];
const { repository, owner, branch } = application;
const { repository, owner, branch } = entity;
if (!repository) reasons.push("1. Repository not assigned.");
if (!owner) reasons.push("2. Owner not specified .");
if (!owner) reasons.push("2. Owner not specified.");
if (!branch) reasons.push("3. Branch not defined.");
return reasons;
};
export const cloneGithubRepository = async (
admin: Admin,
application: Application,
entity: {
appName: string;
repository?: string | null;
owner?: string | null;
branch?: string | null;
},
logPath: string,
isCompose = false,
) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const { appName, repository, owner, branch } = application;
const { appName, repository, owner, branch } = entity;
const requirements = getErrorCloneRequirements(application);
const requirements = getErrorCloneRequirements(entity);
// Check if requirements are met
if (requirements.length > 0) {
@@ -82,7 +92,8 @@ export const cloneGithubRepository = async (
message: "Error: GitHub repository information is incomplete.",
});
}
const outputPath = path.join(APPLICATIONS_PATH, appName);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName);
const octokit = authGithub(admin);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;

View File

@@ -0,0 +1,28 @@
import type { Compose } from "@/server/api/services/compose";
import { COMPOSE_PATH } from "@/server/constants";
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import { recreateDirectory } from "../filesystem/directory";
import { writeFile } from "node:fs/promises";
export const createComposeFile = async (compose: Compose, logPath: string) => {
const { appName, composeFile } = compose;
const writeStream = createWriteStream(logPath, { flags: "a" });
const outputPath = join(COMPOSE_PATH, appName);
try {
await recreateDirectory(outputPath);
writeStream.write(
`\nCreating File 'docker-compose.yml' to ${outputPath}: ✅\n`,
);
await writeFile(join(outputPath, "docker-compose.yml"), composeFile);
writeStream.write(`\nFile 'docker-compose.yml' created: ✅\n`);
} catch (error) {
writeStream.write(`\nERROR Creating Compose File: ${error}: ❌\n`);
throw error;
} finally {
writeStream.end();
}
};

View File

@@ -47,10 +47,7 @@ export const removeDomain = async (appName: string, uniqueKey: number) => {
}
};
export const createRouterConfig = async (
app: ApplicationNested,
domain: Domain,
) => {
const createRouterConfig = async (app: ApplicationNested, domain: Domain) => {
const { appName, redirects, security } = app;
const { certificateType } = domain;

View File

@@ -0,0 +1,72 @@
import type { FileConfig, HttpRouter } from "./file-types";
import type { Registry } from "@/server/api/services/registry";
import { removeDirectoryIfExistsContent } from "../filesystem/directory";
import { REGISTRY_PATH } from "@/server/constants";
import { dump, load } from "js-yaml";
import { join } from "node:path";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
export const manageRegistry = async (registry: Registry) => {
if (!existsSync(REGISTRY_PATH)) {
mkdirSync(REGISTRY_PATH, { recursive: true });
}
const appName = "dokploy-registry";
const config: FileConfig = loadOrCreateConfig();
const serviceName = `${appName}-service`;
const routerName = `${appName}-router`;
config.http = config.http || { routers: {}, services: {} };
config.http.routers = config.http.routers || {};
config.http.services = config.http.services || {};
config.http.routers[routerName] = await createRegistryRouterConfig(registry);
config.http.services[serviceName] = {
loadBalancer: {
servers: [{ url: `http://${appName}:5000` }],
passHostHeader: true,
},
};
const yamlConfig = dump(config);
const configFile = join(REGISTRY_PATH, "registry.yml");
writeFileSync(configFile, yamlConfig);
};
export const removeSelfHostedRegistry = async () => {
await removeDirectoryIfExistsContent(REGISTRY_PATH);
};
const createRegistryRouterConfig = async (registry: Registry) => {
const { registryUrl } = registry;
const routerConfig: HttpRouter = {
rule: `Host(\`${registryUrl}\`)`,
service: "dokploy-registry-service",
middlewares: ["redirect-to-https"],
entryPoints: [
"web",
...(process.env.NODE_ENV === "production" ? ["websecure"] : []),
],
...(process.env.NODE_ENV === "production"
? {
tls: { certResolver: "letsencrypt" },
}
: {}),
};
return routerConfig;
};
const loadOrCreateConfig = (): FileConfig => {
const configPath = join(REGISTRY_PATH, "registry.yml");
if (existsSync(configPath)) {
const yamlStr = readFileSync(configPath, "utf8");
const parsedConfig = (load(yamlStr) as FileConfig) || {
http: { routers: {}, services: {} },
};
return parsedConfig;
}
return { http: { routers: {}, services: {} } };
};

View File

@@ -31,6 +31,10 @@ export const setupDockerStatsMonitoringSocketServer = (
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const appName = url.searchParams.get("appName");
const appType = (url.searchParams.get("appType") || "application") as
| "application"
| "stack"
| "docker-compose";
const { user, session } = await validateWebSocketRequest(req);
if (!appName) {
@@ -47,7 +51,15 @@ export const setupDockerStatsMonitoringSocketServer = (
try {
const filter = {
status: ["running"],
label: [`com.docker.swarm.service.name=${appName}`],
...(appType === "application" && {
label: [`com.docker.swarm.service.name=${appName}`],
}),
...(appType === "stack" && {
label: [`com.docker.swarm.task.name=${appName}`],
}),
...(appType === "docker-compose" && {
name: [appName],
}),
};
const containers = await docker.listContainers({