diff --git a/apps/dokploy/components/dashboard/application/delete-application.tsx b/apps/dokploy/components/dashboard/application/delete-application.tsx index f34d29a7..ff63ef5c 100644 --- a/apps/dokploy/components/dashboard/application/delete-application.tsx +++ b/apps/dokploy/components/dashboard/application/delete-application.tsx @@ -1,5 +1,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -31,6 +32,7 @@ const deleteApplicationSchema = z.object({ projectName: z.string().min(1, { message: "Application name is required", }), + deleteVolumes: z.boolean(), }); type DeleteApplication = z.infer; @@ -50,6 +52,7 @@ export const DeleteApplication = ({ applicationId }: Props) => { const form = useForm({ defaultValues: { projectName: "", + deleteVolumes: false, }, resolver: zodResolver(deleteApplicationSchema), }); @@ -59,6 +62,7 @@ export const DeleteApplication = ({ applicationId }: Props) => { if (formData.projectName === expectedName) { await mutateAsync({ applicationId, + deleteVolumes: formData.deleteVolumes, }) .then((data) => { push(`/dashboard/project/${data?.projectId}`); @@ -134,6 +138,27 @@ export const DeleteApplication = ({ applicationId }: Props) => { )} /> + ( + +
+ + + + + + Delete volumes associated with this compose + +
+ +
+ )} + /> diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 2902c8ed..9b16d579 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -6,6 +6,7 @@ import { import { db } from "@/server/db"; import { apiCreateApplication, + apiDeleteApplication, apiFindMonitoringStats, apiFindOneApplication, apiReloadApplication, @@ -142,7 +143,7 @@ export const applicationRouter = createTRPCRouter({ }), delete: protectedProcedure - .input(apiFindOneApplication) + .input(apiDeleteApplication) .mutation(async ({ input, ctx }) => { if (ctx.user.rol === "user") { await checkServiceAccess( @@ -178,7 +179,11 @@ export const applicationRouter = createTRPCRouter({ async () => await removeTraefikConfig(application.appName, application.serverId), async () => - await removeService(application?.appName, application.serverId), + await removeService( + application?.appName, + application.serverId, + input.deleteVolumes, + ), ]; for (const operation of cleanupOperations) { diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index d9b1a5df..923ea130 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -17,6 +17,7 @@ import { github } from "./github"; import { gitlab } from "./gitlab"; import { mounts } from "./mount"; import { ports } from "./port"; +import { previewDeployments } from "./preview-deployments"; import { projects } from "./project"; import { redirects } from "./redirects"; import { registry } from "./registry"; @@ -25,7 +26,6 @@ import { server } from "./server"; import { applicationStatus, certificateType } from "./shared"; import { sshKeys } from "./ssh-key"; import { generateAppName } from "./utils"; -import { previewDeployments } from "./preview-deployments"; export const sourceType = pgEnum("sourceType", [ "docker", @@ -518,3 +518,8 @@ export const apiUpdateApplication = createSchema applicationId: z.string().min(1), }) .omit({ serverId: true }); + +export const apiDeleteApplication = z.object({ + applicationId: z.string().min(1), + deleteVolumes: z.boolean(), +}); diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 216ee867..e8c9e6c2 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -15,520 +15,528 @@ import { spawnAsync } from "../process/spawnAsync"; import { getRemoteDocker } from "../servers/remote-docker"; interface RegistryAuth { - username: string; - password: string; - registryUrl: string; + username: string; + password: string; + registryUrl: string; } export const pullImage = async ( - dockerImage: string, - onData?: (data: any) => void, - authConfig?: Partial, + dockerImage: string, + onData?: (data: any) => void, + authConfig?: Partial ): Promise => { - try { - if (!dockerImage) { - throw new Error("Docker image not found"); - } + try { + if (!dockerImage) { + throw new Error("Docker image not found"); + } - if (authConfig?.username && authConfig?.password) { - await spawnAsync( - "docker", - [ - "login", - authConfig.registryUrl || "", - "-u", - authConfig.username, - "-p", - authConfig.password, - ], - onData, - ); - } - await spawnAsync("docker", ["pull", dockerImage], onData); - } catch (error) { - throw error; - } + if (authConfig?.username && authConfig?.password) { + await spawnAsync( + "docker", + [ + "login", + authConfig.registryUrl || "", + "-u", + authConfig.username, + "-p", + authConfig.password, + ], + onData + ); + } + await spawnAsync("docker", ["pull", dockerImage], onData); + } catch (error) { + throw error; + } }; export const pullRemoteImage = async ( - dockerImage: string, - serverId: string, - onData?: (data: any) => void, - authConfig?: Partial, + dockerImage: string, + serverId: string, + onData?: (data: any) => void, + authConfig?: Partial ): Promise => { - try { - if (!dockerImage) { - throw new Error("Docker image not found"); - } + try { + if (!dockerImage) { + throw new Error("Docker image not found"); + } - const remoteDocker = await getRemoteDocker(serverId); + const remoteDocker = await getRemoteDocker(serverId); - await new Promise((resolve, reject) => { - remoteDocker.pull( - dockerImage, - { authconfig: authConfig }, - (err, stream) => { - if (err) { - reject(err); - return; - } + await new Promise((resolve, reject) => { + remoteDocker.pull( + dockerImage, + { authconfig: authConfig }, + (err, stream) => { + if (err) { + reject(err); + return; + } - remoteDocker.modem.followProgress( - stream as Readable, - (err: Error | null, res) => { - if (!err) { - resolve(res); - } - if (err) { - reject(err); - } - }, - (event) => { - onData?.(event); - }, - ); - }, - ); - }); - } catch (error) { - throw error; - } + remoteDocker.modem.followProgress( + stream as Readable, + (err: Error | null, res) => { + if (!err) { + resolve(res); + } + if (err) { + reject(err); + } + }, + (event) => { + onData?.(event); + } + ); + } + ); + }); + } catch (error) { + throw error; + } }; export const containerExists = async (containerName: string) => { - const container = docker.getContainer(containerName); - try { - await container.inspect(); - return true; - } catch (error) { - return false; - } + const container = docker.getContainer(containerName); + try { + await container.inspect(); + return true; + } catch (error) { + return false; + } }; export const stopService = async (appName: string) => { - try { - await execAsync(`docker service scale ${appName}=0 `); - } catch (error) { - console.error(error); - return error; - } + try { + await execAsync(`docker service scale ${appName}=0 `); + } catch (error) { + console.error(error); + return error; + } }; export const stopServiceRemote = async (serverId: string, appName: string) => { - try { - await execAsyncRemote(serverId, `docker service scale ${appName}=0 `); - } catch (error) { - console.error(error); - return error; - } + try { + await execAsyncRemote(serverId, `docker service scale ${appName}=0 `); + } catch (error) { + console.error(error); + return error; + } }; export const getContainerByName = (name: string): Promise => { - const opts = { - limit: 1, - filters: { - name: [name], - }, - }; - return new Promise((resolve, reject) => { - docker.listContainers(opts, (err, containers) => { - if (err) { - reject(err); - } else if (containers?.length === 0) { - reject(new Error(`No container found with name: ${name}`)); - } else if (containers && containers?.length > 0 && containers[0]) { - resolve(containers[0]); - } - }); - }); + const opts = { + limit: 1, + filters: { + name: [name], + }, + }; + return new Promise((resolve, reject) => { + docker.listContainers(opts, (err, containers) => { + if (err) { + reject(err); + } else if (containers?.length === 0) { + reject(new Error(`No container found with name: ${name}`)); + } else if (containers && containers?.length > 0 && containers[0]) { + resolve(containers[0]); + } + }); + }); }; export const cleanUpUnusedImages = async (serverId?: string) => { - try { - if (serverId) { - await execAsyncRemote(serverId, "docker image prune --all --force"); - } else { - await execAsync("docker image prune --all --force"); - } - } catch (error) { - console.error(error); - throw error; - } + try { + if (serverId) { + await execAsyncRemote(serverId, "docker image prune --all --force"); + } else { + await execAsync("docker image prune --all --force"); + } + } catch (error) { + console.error(error); + throw error; + } }; export const cleanStoppedContainers = async (serverId?: string) => { - try { - if (serverId) { - await execAsyncRemote(serverId, "docker container prune --force"); - } else { - await execAsync("docker container prune --force"); - } - } catch (error) { - console.error(error); - throw error; - } + try { + if (serverId) { + await execAsyncRemote(serverId, "docker container prune --force"); + } else { + await execAsync("docker container prune --force"); + } + } catch (error) { + console.error(error); + throw error; + } }; export const cleanUpUnusedVolumes = async (serverId?: string) => { - try { - if (serverId) { - await execAsyncRemote(serverId, "docker volume prune --all --force"); - } else { - await execAsync("docker volume prune --all --force"); - } - } catch (error) { - console.error(error); - throw error; - } + try { + if (serverId) { + await execAsyncRemote(serverId, "docker volume prune --all --force"); + } else { + await execAsync("docker volume prune --all --force"); + } + } catch (error) { + console.error(error); + throw error; + } }; export const cleanUpInactiveContainers = async () => { - try { - const containers = await docker.listContainers({ all: true }); - const inactiveContainers = containers.filter( - (container) => container.State !== "running", - ); + try { + const containers = await docker.listContainers({ all: true }); + const inactiveContainers = containers.filter( + (container) => container.State !== "running" + ); - for (const container of inactiveContainers) { - await docker.getContainer(container.Id).remove({ force: true }); - console.log(`Cleaning up inactive container: ${container.Id}`); - } - } catch (error) { - console.error("Error cleaning up inactive containers:", error); - throw error; - } + for (const container of inactiveContainers) { + await docker.getContainer(container.Id).remove({ force: true }); + console.log(`Cleaning up inactive container: ${container.Id}`); + } + } catch (error) { + console.error("Error cleaning up inactive containers:", error); + throw error; + } }; export const cleanUpDockerBuilder = async (serverId?: string) => { - if (serverId) { - await execAsyncRemote(serverId, "docker builder prune --all --force"); - } else { - await execAsync("docker builder prune --all --force"); - } + if (serverId) { + await execAsyncRemote(serverId, "docker builder prune --all --force"); + } else { + await execAsync("docker builder prune --all --force"); + } }; export const cleanUpSystemPrune = async (serverId?: string) => { - if (serverId) { - await execAsyncRemote( - serverId, - "docker system prune --all --force --volumes", - ); - } else { - await execAsync("docker system prune --all --force --volumes"); - } + if (serverId) { + await execAsyncRemote( + serverId, + "docker system prune --all --force --volumes" + ); + } else { + await execAsync("docker system prune --all --force --volumes"); + } }; export const startService = async (appName: string) => { - try { - await execAsync(`docker service scale ${appName}=1 `); - } catch (error) { - console.error(error); - throw error; - } + try { + await execAsync(`docker service scale ${appName}=1 `); + } catch (error) { + console.error(error); + throw error; + } }; export const startServiceRemote = async (serverId: string, appName: string) => { - try { - await execAsyncRemote(serverId, `docker service scale ${appName}=1 `); - } catch (error) { - console.error(error); - throw error; - } + try { + await execAsyncRemote(serverId, `docker service scale ${appName}=1 `); + } catch (error) { + console.error(error); + throw error; + } }; export const removeService = async ( - appName: string, - serverId?: string | null, + appName: string, + serverId?: string | null, + deleteVolumes = false ) => { - try { - const command = `docker service rm ${appName}`; - if (serverId) { - await execAsyncRemote(serverId, command); - } else { - await execAsync(command); - } - } catch (error) { - return error; - } + try { + let command: string; + + if (deleteVolumes) { + command = `docker service rm --force ${appName}`; + } else { + command = `docker service rm ${appName}`; + } + + if (serverId) { + await execAsyncRemote(serverId, command); + } else { + await execAsync(command); + } + } catch (error) { + return error; + } }; export const prepareEnvironmentVariables = ( - serviceEnv: string | null, - projectEnv?: string | null, + serviceEnv: string | null, + projectEnv?: string | null ) => { - const projectVars = parse(projectEnv ?? ""); - const serviceVars = parse(serviceEnv ?? ""); + const projectVars = parse(projectEnv ?? ""); + const serviceVars = parse(serviceEnv ?? ""); - const resolvedVars = Object.entries(serviceVars).map(([key, value]) => { - let resolvedValue = value; - if (projectVars) { - resolvedValue = value.replace(/\$\{\{project\.(.*?)\}\}/g, (_, ref) => { - if (projectVars[ref] !== undefined) { - return projectVars[ref]; - } - throw new Error(`Invalid project environment variable: project.${ref}`); - }); - } - return `${key}=${resolvedValue}`; - }); + const resolvedVars = Object.entries(serviceVars).map(([key, value]) => { + let resolvedValue = value; + if (projectVars) { + resolvedValue = value.replace(/\$\{\{project\.(.*?)\}\}/g, (_, ref) => { + if (projectVars[ref] !== undefined) { + return projectVars[ref]; + } + throw new Error(`Invalid project environment variable: project.${ref}`); + }); + } + return `${key}=${resolvedValue}`; + }); - return resolvedVars; + return resolvedVars; }; export const prepareBuildArgs = (input: string | null) => { - const pairs = (input ?? "").split("\n"); + const pairs = (input ?? "").split("\n"); - const jsonObject: Record = {}; + const jsonObject: Record = {}; - for (const pair of pairs) { - const [key, value] = pair.split("="); - if (key && value) { - jsonObject[key] = value; - } - } + for (const pair of pairs) { + const [key, value] = pair.split("="); + if (key && value) { + jsonObject[key] = value; + } + } - return jsonObject; + return jsonObject; }; export const generateVolumeMounts = (mounts: ApplicationNested["mounts"]) => { - if (!mounts || mounts.length === 0) { - return []; - } + if (!mounts || mounts.length === 0) { + return []; + } - return mounts - .filter((mount) => mount.type === "volume") - .map((mount) => ({ - Type: "volume" as const, - Source: mount.volumeName || "", - Target: mount.mountPath, - })); + return mounts + .filter((mount) => mount.type === "volume") + .map((mount) => ({ + Type: "volume" as const, + Source: mount.volumeName || "", + Target: mount.mountPath, + })); }; type Resources = { - memoryLimit: number | null; - memoryReservation: number | null; - cpuLimit: number | null; - cpuReservation: number | null; + memoryLimit: number | null; + memoryReservation: number | null; + cpuLimit: number | null; + cpuReservation: number | null; }; export const calculateResources = ({ - memoryLimit, - memoryReservation, - cpuLimit, - cpuReservation, + memoryLimit, + memoryReservation, + cpuLimit, + cpuReservation, }: Resources): ResourceRequirements => { - return { - Limits: { - MemoryBytes: memoryLimit ?? undefined, - NanoCPUs: cpuLimit ?? undefined, - }, - Reservations: { - MemoryBytes: memoryReservation ?? undefined, - NanoCPUs: cpuReservation ?? undefined, - }, - }; + return { + Limits: { + MemoryBytes: memoryLimit ?? undefined, + NanoCPUs: cpuLimit ?? undefined, + }, + Reservations: { + MemoryBytes: memoryReservation ?? undefined, + NanoCPUs: cpuReservation ?? undefined, + }, + }; }; export const generateConfigContainer = (application: ApplicationNested) => { - const { - healthCheckSwarm, - restartPolicySwarm, - placementSwarm, - updateConfigSwarm, - rollbackConfigSwarm, - modeSwarm, - labelsSwarm, - replicas, - mounts, - networkSwarm, - } = application; + const { + healthCheckSwarm, + restartPolicySwarm, + placementSwarm, + updateConfigSwarm, + rollbackConfigSwarm, + modeSwarm, + labelsSwarm, + replicas, + mounts, + networkSwarm, + } = application; - const haveMounts = mounts.length > 0; + const haveMounts = mounts.length > 0; - return { - ...(healthCheckSwarm && { - HealthCheck: healthCheckSwarm, - }), - ...(restartPolicySwarm - ? { - RestartPolicy: restartPolicySwarm, - } - : {}), - ...(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" }], - }), - }; + return { + ...(healthCheckSwarm && { + HealthCheck: healthCheckSwarm, + }), + ...(restartPolicySwarm + ? { + RestartPolicy: restartPolicySwarm, + } + : {}), + ...(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 []; - } + if (!mounts || mounts.length === 0) { + return []; + } - return mounts - .filter((mount) => mount.type === "bind") - .map((mount) => ({ - Type: "bind" as const, - Source: mount.hostPath || "", - Target: mount.mountPath, - })); + return mounts + .filter((mount) => mount.type === "bind") + .map((mount) => ({ + Type: "bind" as const, + Source: mount.hostPath || "", + Target: mount.mountPath, + })); }; export const generateFileMounts = ( - appName: string, - service: - | ApplicationNested - | MongoNested - | MariadbNested - | MysqlNested - | PostgresNested - | RedisNested, + appName: string, + service: + | ApplicationNested + | MongoNested + | MariadbNested + | MysqlNested + | PostgresNested + | RedisNested ) => { - const { mounts } = service; - const { APPLICATIONS_PATH } = paths(!!service.serverId); - if (!mounts || mounts.length === 0) { - return []; - } + const { mounts } = service; + const { APPLICATIONS_PATH } = paths(!!service.serverId); + if (!mounts || mounts.length === 0) { + return []; + } - return mounts - .filter((mount) => mount.type === "file") - .map((mount) => { - const fileName = mount.filePath; - const absoluteBasePath = path.resolve(APPLICATIONS_PATH); - const directory = path.join(absoluteBasePath, appName, "files"); - const sourcePath = path.join(directory, fileName || ""); - return { - Type: "bind" as const, - Source: sourcePath, - Target: mount.mountPath, - }; - }); + return mounts + .filter((mount) => mount.type === "file") + .map((mount) => { + const fileName = mount.filePath; + const absoluteBasePath = path.resolve(APPLICATIONS_PATH); + const directory = path.join(absoluteBasePath, appName, "files"); + const sourcePath = path.join(directory, fileName || ""); + return { + Type: "bind" as const, + Source: sourcePath, + Target: mount.mountPath, + }; + }); }; export const createFile = async ( - outputPath: string, - filePath: string, - content: string, + outputPath: string, + filePath: string, + content: string ) => { - try { - const fullPath = path.join(outputPath, filePath); - if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) { - fs.mkdirSync(fullPath, { recursive: true }); - return; - } + try { + const fullPath = path.join(outputPath, filePath); + if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) { + fs.mkdirSync(fullPath, { recursive: true }); + return; + } - const directory = path.dirname(fullPath); - fs.mkdirSync(directory, { recursive: true }); - fs.writeFileSync(fullPath, content || ""); - } catch (error) { - throw error; - } + const directory = path.dirname(fullPath); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(fullPath, content || ""); + } catch (error) { + throw error; + } }; export const encodeBase64 = (content: string) => - Buffer.from(content, "utf-8").toString("base64"); + Buffer.from(content, "utf-8").toString("base64"); export const getCreateFileCommand = ( - outputPath: string, - filePath: string, - content: string, + outputPath: string, + filePath: string, + content: string ) => { - const fullPath = path.join(outputPath, filePath); - if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) { - return `mkdir -p ${fullPath};`; - } + const fullPath = path.join(outputPath, filePath); + if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) { + return `mkdir -p ${fullPath};`; + } - const directory = path.dirname(fullPath); - const encodedContent = encodeBase64(content); - return ` + const directory = path.dirname(fullPath); + const encodedContent = encodeBase64(content); + return ` mkdir -p ${directory}; echo "${encodedContent}" | base64 -d > "${fullPath}"; `; }; export const getServiceContainer = async (appName: string) => { - try { - const filter = { - status: ["running"], - label: [`com.docker.swarm.service.name=${appName}`], - }; + try { + const filter = { + status: ["running"], + label: [`com.docker.swarm.service.name=${appName}`], + }; - const containers = await docker.listContainers({ - filters: JSON.stringify(filter), - }); + const containers = await docker.listContainers({ + filters: JSON.stringify(filter), + }); - if (containers.length === 0 || !containers[0]) { - throw new Error(`No container found with name: ${appName}`); - } + if (containers.length === 0 || !containers[0]) { + throw new Error(`No container found with name: ${appName}`); + } - const container = containers[0]; + const container = containers[0]; - return container; - } catch (error) { - throw error; - } + return container; + } catch (error) { + throw error; + } }; export const getRemoteServiceContainer = async ( - serverId: string, - appName: string, + serverId: string, + appName: string ) => { - try { - const filter = { - status: ["running"], - label: [`com.docker.swarm.service.name=${appName}`], - }; - const remoteDocker = await getRemoteDocker(serverId); - const containers = await remoteDocker.listContainers({ - filters: JSON.stringify(filter), - }); + try { + const filter = { + status: ["running"], + label: [`com.docker.swarm.service.name=${appName}`], + }; + const remoteDocker = await getRemoteDocker(serverId); + const containers = await remoteDocker.listContainers({ + filters: JSON.stringify(filter), + }); - if (containers.length === 0 || !containers[0]) { - throw new Error(`No container found with name: ${appName}`); - } + if (containers.length === 0 || !containers[0]) { + throw new Error(`No container found with name: ${appName}`); + } - const container = containers[0]; + const container = containers[0]; - return container; - } catch (error) { - throw error; - } + return container; + } catch (error) { + throw error; + } };