Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 40 additions & 7 deletions apps/dokploy/__test__/settings/docker-server-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const mocks = vi.hoisted(() => ({
sendDockerCleanupNotifications: vi.fn(),
setupGPUSupport: vi.fn(),
spawnAsync: vi.fn(),
startServerUpdate: vi.fn(),
getServerUpdateStatus: vi.fn(),
startLogCleanup: vi.fn(),
stopLogCleanup: vi.fn(),
updateLetsEncryptEmail: vi.fn(),
Expand Down Expand Up @@ -114,6 +116,8 @@ vi.mock("@dokploy/server", () => ({
sendDockerCleanupNotifications: mocks.sendDockerCleanupNotifications,
setupGPUSupport: mocks.setupGPUSupport,
spawnAsync: mocks.spawnAsync,
startServerUpdate: mocks.startServerUpdate,
getServerUpdateStatus: mocks.getServerUpdateStatus,
startLogCleanup: mocks.startLogCleanup,
stopLogCleanup: mocks.stopLogCleanup,
updateLetsEncryptEmail: mocks.updateLetsEncryptEmail,
Expand Down Expand Up @@ -427,10 +431,9 @@ describe("settings Docker server boundary", () => {
"v0.30.0",
undefined,
);
expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [
"-c",
expect(mocks.startServerUpdate).toHaveBeenCalledWith(
"agenthits update command",
]);
);
});

it("passes the image keep count to the AgentHits update command", async () => {
Expand Down Expand Up @@ -461,7 +464,7 @@ describe("settings Docker server boundary", () => {
await expect(
createCaller().updateServer({ keepImages: 10 }),
).rejects.toThrow();
expect(mocks.spawnAsync).not.toHaveBeenCalled();
expect(mocks.startServerUpdate).not.toHaveBeenCalled();
});

it("keeps official updates on the official Dokploy image path", async () => {
Expand All @@ -479,10 +482,9 @@ describe("settings Docker server boundary", () => {
"v0.30.0",
undefined,
);
expect(mocks.spawnAsync).toHaveBeenCalledWith("sh", [
"-c",
expect(mocks.startServerUpdate).toHaveBeenCalledWith(
"official update command",
]);
);
expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled();
});

Expand All @@ -503,6 +505,37 @@ describe("settings Docker server boundary", () => {
);
});

it("does not start an update when none is available", async () => {
mocks.getUpdateData.mockResolvedValue({
latestVersion: null,
updateAvailable: false,
updateSource: "official",
});

await expect(createCaller().updateServer()).resolves.toBe(true);

expect(mocks.startServerUpdate).not.toHaveBeenCalled();
});

it("returns the web server update status", async () => {
const status = {
phase: "pulling",
startedAt: 1,
pulledAt: null,
finishedAt: null,
error: null,
layersTotal: 3,
layersDownloaded: 1,
layersExtracted: 0,
output: [],
};
mocks.getServerUpdateStatus.mockReturnValue(status);

await expect(createCaller().getServerUpdateStatus()).resolves.toEqual(
status,
);
});

it("requires api.read before generating the OpenAPI document", async () => {
const result = await createCaller().getOpenApiDocument();

Expand Down
13 changes: 13 additions & 0 deletions apps/dokploy/__test__/settings/dokploy-image-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { execAsync } = vi.hoisted(() => ({ execAsync: vi.fn() }));
Expand All @@ -20,6 +21,9 @@ const {
getDokployImages,
getOfficialUpdateCommand,
} = await import("@dokploy/server/services/settings");
const { UPDATE_IMAGE_PULLED_MARKER } = await import(
"@dokploy/server/services/web-server-update"
);

describe("Dokploy image cleanup", () => {
beforeEach(() => {
Expand Down Expand Up @@ -216,6 +220,15 @@ describe("Dokploy update commands", () => {
expect(command.indexOf(pull)).toBeLessThan(
command.indexOf("docker service update"),
);

const lines = command.split("\n");
const markerLine = lines.findIndex((line) => line.startsWith("echo "));
expect(lines[markerLine - 1]).toBe(`${pull} || exit 1`);
expect(
execFileSync("sh", ["-c", lines[markerLine] as string], {
encoding: "utf8",
}).trim(),
).toBe(UPDATE_IMAGE_PULLED_MARKER);
};

it("pulls the AgentHits image while the old container still runs", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ const mocks = vi.hoisted(() => ({
sendDockerCleanupNotifications: vi.fn(),
setupGPUSupport: vi.fn(),
spawnAsync: vi.fn(),
startServerUpdate: vi.fn(),
getServerUpdateStatus: vi.fn(),
startLogCleanup: vi.fn(),
stopLogCleanup: vi.fn(),
updateLetsEncryptEmail: vi.fn(),
Expand Down Expand Up @@ -136,6 +138,8 @@ vi.mock("@dokploy/server", () => ({
sendDockerCleanupNotifications: mocks.sendDockerCleanupNotifications,
setupGPUSupport: mocks.setupGPUSupport,
spawnAsync: mocks.spawnAsync,
startServerUpdate: mocks.startServerUpdate,
getServerUpdateStatus: mocks.getServerUpdateStatus,
startLogCleanup: mocks.startLogCleanup,
stopLogCleanup: mocks.stopLogCleanup,
updateLetsEncryptEmail: mocks.updateLetsEncryptEmail,
Expand Down
249 changes: 249 additions & 0 deletions apps/dokploy/__test__/settings/update-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { ServerUpdateStatus } from "@dokploy/server/services/web-server-update";
import { describe, expect, it } from "vitest";
import {
API_WAIT_MS,
advanceUpdateProgress,
createUpdateProgress,
DOWNLOAD_LIMIT_MS,
failUpdateProgress,
formatElapsed,
getUpdateStepState,
RESTART_LIMIT_MS,
UPDATE_STEPS,
type UpdateProgress,
type UpdateProgressEvent,
} from "@/components/dashboard/settings/web-server/update-progress";

const serverStatus = (
overrides: Partial<ServerUpdateStatus> = {},
): ServerUpdateStatus => ({
phase: "pulling",
startedAt: 1_000,
pulledAt: null,
finishedAt: null,
error: null,
layersTotal: 0,
layersDownloaded: 0,
layersExtracted: 0,
output: [],
...overrides,
});

const run = (events: UpdateProgressEvent[], start = createUpdateProgress(0)) =>
events.reduce<UpdateProgress>(advanceUpdateProgress, start);

const stepStates = (progress: UpdateProgress) =>
UPDATE_STEPS.map((step) => getUpdateStepState(progress, step));

describe("update progress", () => {
it("walks through download, restart and start to done", () => {
const pulling = run([
{ type: "status", status: serverStatus(), at: 2_000 },
]);
expect(pulling.phase).toBe("downloading");
expect(stepStates(pulling)).toEqual([
"active",
"pending",
"pending",
"pending",
]);

const down = run(
[
{
type: "status",
status: serverStatus({ phase: "updating", pulledAt: 50_000 }),
at: 60_000,
},
{ type: "down", at: 62_000 },
],
pulling,
);
expect(down).toMatchObject({ phase: "restarting", downAt: 62_000 });
expect(stepStates(down)).toEqual(["done", "active", "pending", "pending"]);

const up = advanceUpdateProgress(down, { type: "up", at: 80_000 });
expect(up).toMatchObject({ phase: "starting", upAt: 80_000 });

const done = advanceUpdateProgress(up, {
type: "status",
status: serverStatus({ phase: "idle", startedAt: null }),
at: 82_000,
});
expect(done).toMatchObject({ phase: "done", finishedAt: 82_000 });
expect(stepStates(done)).toEqual(["done", "done", "done", "done"]);
expect(done.server?.phase).toBe("updating");
});

it("keeps the first down time when the new server flaps", () => {
const progress = run([
{ type: "down", at: 10_000 },
{ type: "up", at: 20_000 },
{ type: "down", at: 22_000 },
]);

expect(progress).toMatchObject({
phase: "restarting",
downAt: 10_000,
upAt: null,
});
});

it("ignores health answers while the old server is still pulling", () => {
const progress = run([{ type: "up", at: 2_000 }]);

expect(progress.phase).toBe("downloading");
});

it("fails on the download step when the pull failed", () => {
const progress = run([
{
type: "status",
status: serverStatus({
phase: "failed",
error: "Downloading the new image failed.",
output: ["manifest unknown"],
}),
at: 5_000,
},
]);

expect(progress).toMatchObject({
phase: "failed",
failedStep: "downloading",
error: "Downloading the new image failed.",
});
expect(progress.server?.output).toEqual(["manifest unknown"]);
expect(stepStates(progress)).toEqual([
"failed",
"pending",
"pending",
"pending",
]);
});

it("fails on the restart step when the service update failed", () => {
const progress = run([
{
type: "status",
status: serverStatus({
phase: "failed",
pulledAt: 3_000,
error: "docker service update failed.",
}),
at: 5_000,
},
]);

expect(progress.failedStep).toBe("restarting");
expect(stepStates(progress)).toEqual([
"done",
"failed",
"pending",
"pending",
]);
});

it("fails when the server never started an update job", () => {
const progress = run([
{
type: "status",
status: serverStatus({ phase: "idle", startedAt: null }),
at: 2_000,
},
]);

expect(progress).toMatchObject({
phase: "failed",
failedStep: "downloading",
});
expect(progress.error).toContain("did not start an update");
});

it("treats an idle status after a seen job as a restart that was missed", () => {
const progress = run([
{ type: "status", status: serverStatus(), at: 2_000 },
{
type: "status",
status: serverStatus({ phase: "idle", startedAt: null }),
at: 400_000,
},
]);

expect(progress.phase).toBe("done");
});

it("fails when the server does not go down within the download limit", () => {
const waiting = run([
{ type: "status", status: serverStatus(), at: 2_000 },
{ type: "tick", at: DOWNLOAD_LIMIT_MS },
]);
expect(waiting.phase).toBe("downloading");

const progress = advanceUpdateProgress(waiting, {
type: "tick",
at: DOWNLOAD_LIMIT_MS + 1,
});
expect(progress).toMatchObject({
phase: "failed",
failedStep: "downloading",
error: "Dokploy did not restart within 20 minutes.",
});
});

it("fails when the server does not come back after going down", () => {
const progress = run([
{ type: "down", at: 10_000 },
{ type: "tick", at: 10_000 + RESTART_LIMIT_MS + 1 },
]);

expect(progress).toMatchObject({
phase: "failed",
failedStep: "restarting",
});
expect(stepStates(progress)).toEqual([
"done",
"failed",
"pending",
"pending",
]);
});

it("finishes when health is fine but the API keeps failing", () => {
const starting = run([
{ type: "down", at: 10_000 },
{ type: "up", at: 30_000 },
{ type: "tick", at: 30_000 + API_WAIT_MS },
]);
expect(starting.phase).toBe("starting");

expect(
advanceUpdateProgress(starting, {
type: "tick",
at: 30_000 + API_WAIT_MS + 1,
}).phase,
).toBe("done");
});

it("does not change once finished", () => {
const failed = failUpdateProgress(
createUpdateProgress(0),
"downloading",
"Could not start the update.",
1_000,
);

expect(advanceUpdateProgress(failed, { type: "down", at: 2_000 })).toBe(
failed,
);
});
});

describe("formatElapsed", () => {
it("formats seconds and minutes", () => {
expect(formatElapsed(-5)).toBe("0 s");
expect(formatElapsed(19_900)).toBe("19 s");
expect(formatElapsed(65_000)).toBe("1 min 05 s");
expect(formatElapsed(20 * 60 * 1000)).toBe("20 min 00 s");
});
});
Loading
Loading