import { readdirSync } from "node:fs"; import { join } from "node:path"; import { docker } from "@dokploy/server/constants"; import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; import semver from "semver"; import { initializeStandaloneTraefik, initializeTraefikService, type TraefikOptions, } from "../setup/traefik-setup"; export interface IUpdateData { latestVersion: string | null; updateAvailable: boolean; } export const DEFAULT_UPDATE_DATA: IUpdateData = { latestVersion: null, updateAvailable: false, }; /** Returns current Dokploy docker image tag or `latest` by default. */ export const getDokployImageTag = () => { return process.env.RELEASE_TAG || "latest"; }; export const getDokployImage = () => { return `dokploy/dokploy:${getDokployImageTag()}`; }; export const pullLatestRelease = async () => { const stream = await docker.pull(getDokployImage()); await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (err, res) => err ? reject(err) : resolve(res), ); }); }; /** Returns Dokploy docker service image digest */ export const getServiceImageDigest = async () => { const { stdout } = await execAsync( "docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'", ); const currentDigest = stdout.trim().split("@")[1]; if (!currentDigest) { throw new Error("Could not get current service image digest"); } return currentDigest; }; /** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */ export const getUpdateData = async ( currentVersion: string, ): Promise => { try { const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags"; let url: string | null = `${baseUrl}?page_size=100`; let allResults: { digest: string; name: string }[] = []; // Fetch all tags from Docker Hub while (url) { const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json" }, }); const data = (await response.json()) as { next: string | null; results: { digest: string; name: string }[]; }; allResults = allResults.concat(data.results); url = data?.next; } const currentImageTag = getDokployImageTag(); // Special handling for canary and feature branches // For development versions (canary/feature), don't perform update checks // These are unstable versions that change frequently, and users on these // branches are expected to manually manage updates if (currentImageTag === "canary" || currentImageTag === "feature") { const currentDigest = await getServiceImageDigest(); const latestDigest = allResults.find( (t) => t.name === currentImageTag, )?.digest; if (!latestDigest) { return DEFAULT_UPDATE_DATA; } if (currentDigest !== latestDigest) { return { latestVersion: currentImageTag, updateAvailable: true, }; } return { latestVersion: currentImageTag, updateAvailable: false, }; } // For stable versions, use semver comparison // Find the "latest" tag and get its digest const latestTag = allResults.find((t) => t.name === "latest"); if (!latestTag) { return DEFAULT_UPDATE_DATA; } // Find the versioned tag (v0.x.x) that has the same digest as "latest" const latestVersionTag = allResults.find( (t) => t.digest === latestTag.digest && t.name.startsWith("v"), ); if (!latestVersionTag) { return DEFAULT_UPDATE_DATA; } const latestVersion = latestVersionTag.name; // Use semver to compare versions for stable releases const cleanedCurrent = semver.clean(currentVersion); const cleanedLatest = semver.clean(latestVersion); if (!cleanedCurrent || !cleanedLatest) { return DEFAULT_UPDATE_DATA; } // Check if the latest version is greater than the current version const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent); return { latestVersion, updateAvailable, }; } catch (error) { console.error("Error fetching update data:", error); return DEFAULT_UPDATE_DATA; } }; interface TreeDataItem { id: string; name: string; type: "file" | "directory"; children?: TreeDataItem[]; } export const readDirectory = async ( dirPath: string, serverId?: string, ): Promise => { if (serverId) { const { stdout } = await execAsyncRemote( serverId, ` process_items() { local parent_dir="$1" local __resultvar=$2 local items_json="" local first=true for item in "$parent_dir"/*; do [ -e "$item" ] || continue process_item "$item" item_json if [ "$first" = true ]; then first=false items_json="$item_json" else items_json="$items_json,$item_json" fi done eval $__resultvar="'[$items_json]'" } process_item() { local item_path="$1" local __resultvar=$2 local item_name=$(basename "$item_path") local escaped_name=$(echo "$item_name" | sed 's/"/\\"/g') local escaped_path=$(echo "$item_path" | sed 's/"/\\"/g') if [ -d "$item_path" ]; then # Is directory process_items "$item_path" children_json local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"directory","children":'"$children_json"'}' else # Is file local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"file"}' fi eval $__resultvar="'$json'" } root_dir=${dirPath} process_items "$root_dir" json_output echo "$json_output" `, ); const result = JSON.parse(stdout); return result; } const stack = [dirPath]; const result: TreeDataItem[] = []; const parentMap: Record = {}; while (stack.length > 0) { const currentPath = stack.pop(); if (!currentPath) continue; const items = readdirSync(currentPath, { withFileTypes: true }); const currentDirectoryResult: TreeDataItem[] = []; for (const item of items) { const fullPath = join(currentPath, item.name); if (item.isDirectory()) { stack.push(fullPath); const directoryItem: TreeDataItem = { id: fullPath, name: item.name, type: "directory", children: [], }; currentDirectoryResult.push(directoryItem); parentMap[fullPath] = directoryItem.children as TreeDataItem[]; } else { const fileItem: TreeDataItem = { id: fullPath, name: item.name, type: "file", }; currentDirectoryResult.push(fileItem); } } if (parentMap[currentPath]) { parentMap[currentPath].push(...currentDirectoryResult); } else { result.push(...currentDirectoryResult); } } return result; }; export const getDockerResourceType = async ( resourceName: string, serverId?: string, ) => { try { let result = ""; const command = ` RESOURCE_NAME="${resourceName}" if docker service inspect "$RESOURCE_NAME" >/dev/null 2>&1; then echo "service" elif docker inspect "$RESOURCE_NAME" >/dev/null 2>&1; then echo "standalone" else echo "unknown" fi`; if (serverId) { const { stdout } = await execAsyncRemote(serverId, command); result = stdout.trim(); } else { const { stdout } = await execAsync(command); result = stdout.trim(); } if (result === "service") { return "service"; } if (result === "standalone") { return "standalone"; } return "unknown"; } catch (error) { console.error(error); return "unknown"; } }; export const reloadDockerResource = async ( resourceName: string, serverId?: string, version?: string, ) => { const resourceType = await getDockerResourceType(resourceName, serverId); let command = ""; if (resourceType === "service") { if (resourceName === "dokploy") { const currentImageTag = getDokployImageTag(); let imageTag = version; if (currentImageTag === "canary" || currentImageTag === "feature") { imageTag = currentImageTag; } command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`; } else { command = `docker service update --force ${resourceName}`; } } else if (resourceType === "standalone") { command = `docker restart ${resourceName}`; } else { throw new Error("Resource type not found"); } if (serverId) { await execAsyncRemote(serverId, command); } else { await execAsync(command); } }; export const readEnvironmentVariables = async ( resourceName: string, serverId?: string, ) => { const resourceType = await getDockerResourceType(resourceName, serverId); let command = ""; if (resourceType === "service") { command = `docker service inspect ${resourceName} --format '{{json .Spec.TaskTemplate.ContainerSpec.Env}}'`; } else if (resourceType === "standalone") { command = `docker container inspect ${resourceName} --format '{{json .Config.Env}}'`; } let result = ""; if (serverId) { const { stdout } = await execAsyncRemote(serverId, command); result = stdout.trim(); } else { const { stdout } = await execAsync(command); result = stdout.trim(); } if (result === "null") { return ""; } return JSON.parse(result)?.join("\n"); }; export const readPorts = async ( resourceName: string, serverId?: string, ): Promise< { targetPort: number; publishedPort: number; protocol?: string }[] > => { const resourceType = await getDockerResourceType(resourceName, serverId); let command = ""; if (resourceType === "service") { command = `docker service inspect ${resourceName} --format '{{json .Spec.EndpointSpec.Ports}}'`; } else if (resourceType === "standalone") { command = `docker container inspect ${resourceName} --format '{{json .NetworkSettings.Ports}}'`; } else { throw new Error("Resource type not found"); } let result = ""; if (serverId) { const { stdout } = await execAsyncRemote(serverId, command); result = stdout.trim(); } else { const { stdout } = await execAsync(command); result = stdout.trim(); } if (result === "null") { return []; } const parsedResult = JSON.parse(result); if (resourceType === "service") { return parsedResult .map((port: any) => ({ targetPort: port.TargetPort, publishedPort: port.PublishedPort, protocol: port.Protocol, })) .filter((port: any) => port.targetPort !== 80 && port.targetPort !== 443); } const ports: { targetPort: number; publishedPort: number; protocol?: string; }[] = []; const seenPorts = new Set(); for (const key in parsedResult) { if (Object.hasOwn(parsedResult, key)) { const containerPortMapppings = parsedResult[key]; const protocol = key.split("/")[1]; const targetPort = Number.parseInt(key.split("/")[0] ?? "0", 10); // Take only the first mapping to avoid duplicates (IPv4 and IPv6) const firstMapping = containerPortMapppings[0]; if (firstMapping) { const publishedPort = Number.parseInt(firstMapping.HostPort, 10); const portKey = `${targetPort}-${publishedPort}-${protocol}`; if (!seenPorts.has(portKey)) { seenPorts.add(portKey); ports.push({ targetPort: targetPort, publishedPort: publishedPort, protocol: protocol, }); } } } } return ports.filter( (port: any) => port.targetPort !== 80 && port.targetPort !== 443, ); }; export const checkPortInUse = async ( port: number, serverId?: string, ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { try { const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; const { stdout } = serverId ? await execAsyncRemote(serverId, command) : await execAsync(command); const container = stdout.trim(); return { isInUse: !!container, conflictingContainer: container || undefined, }; } catch (error) { console.error("Error checking port availability:", error); return { isInUse: false }; } }; export const writeTraefikSetup = async (input: TraefikOptions) => { const resourceType = await getDockerResourceType( "dokploy-traefik", input.serverId, ); if (resourceType === "service") { await initializeTraefikService({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, }); } else if (resourceType === "standalone") { await initializeStandaloneTraefik({ env: input.env, additionalPorts: input.additionalPorts, serverId: input.serverId, }); } else { throw new Error("Traefik resource type not found"); } };