Docker compose support (#111)

* feat(WIP): compose implementation

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

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

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

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

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

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

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

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

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

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

* chore: add templates section to contributing

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

View File

@@ -20,6 +20,7 @@ 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";
/**
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
security: securityRouter,
redirects: redirectsRouter,
port: portRouter,
compose: composeRouter,
registry: registryRouter,
cluster: clusterRouter,
});

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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