mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 22:25:22 +02:00
- Added logic to getUpdateData to compare current and latest image digests for canary and feature tags, indicating if an update is available. - Updated reloadDockerResource to ensure the correct image tag is used during dokploy service updates based on the current image tag.
465 lines
12 KiB
TypeScript
465 lines
12 KiB
TypeScript
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<IUpdateData> => {
|
|
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<TreeDataItem[]> => {
|
|
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<string, TreeDataItem[]> = {};
|
|
|
|
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<string>();
|
|
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");
|
|
}
|
|
};
|