diff --git a/apps/dokploy/__test__/server/server-setup-exit-code.test.ts b/apps/dokploy/__test__/server/server-setup-exit-code.test.ts new file mode 100644 index 000000000..58c273cd9 --- /dev/null +++ b/apps/dokploy/__test__/server/server-setup-exit-code.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const { EventEmitter } = require("node:events") as { + EventEmitter: typeof import("node:events").EventEmitter; + }; + + class FakeStream extends EventEmitter { + stderr: InstanceType; + constructor() { + super(); + this.stderr = new EventEmitter(); + } + } + + let capturedCommand = ""; + // Per-test configuration of the remote script's behavior. The fake ssh2 + // Client replays these when its exec() is invoked, so each test drives the + // real production defaultCommand(...) string through installRequirements + // with a chosen exit outcome. + let exitCode: number | null = 0; + let stderrChunk: string | null = null; + let stdoutChunk: string | null = null; + + class FakeClient extends EventEmitter { + connect(_opts: unknown) { + process.nextTick(() => this.emit("ready")); + } + exec(cmd: string, cb: (err: Error | null, stream: unknown) => void) { + capturedCommand = cmd; + const stream = new FakeStream(); + cb(null, stream); + process.nextTick(() => { + if (stdoutChunk) stream.emit("data", Buffer.from(stdoutChunk)); + if (stderrChunk) stream.stderr.emit("data", Buffer.from(stderrChunk)); + // ssh2 emits the remote exit code as the first arg of "close" + // (see ssh2 utils.js: channel.emit('close', exit.code)). A null + // code means the process was killed by a signal. + stream.emit("close", exitCode); + }); + } + end() {} + } + + return { + FakeClient, + getCapturedCommand: () => capturedCommand, + configure: (opts: { + exitCode?: number | null; + stderr?: string | null; + stdout?: string | null; + }) => { + // `?? 0` would coerce an explicit `null` (signal-kill) back to 0; + // only fall back to 0 when the option is omitted entirely. + exitCode = opts.exitCode !== undefined ? opts.exitCode : 0; + stderrChunk = opts.stderr ?? null; + stdoutChunk = opts.stdout ?? null; + }, + findServerById: vi.fn(async () => ({ + serverId: "srv-1", + name: "my-server", + serverType: "deploy", + sshKeyId: "key-1", + ipAddress: "1.2.3.4", + port: 22, + username: "root", + sshKey: { privateKey: "PK" }, + metricsConfig: { + server: { token: "", urlCallback: "", cronJob: "" }, + containers: {}, + }, + // No `command` override -> installRequirements uses defaultCommand(...), + // exactly as in production when the operator has not customized the + // script. + command: "", + })), + updateServerById: vi.fn(async () => ({})), + createServerDeployment: vi.fn(async () => ({ deploymentId: "dep-1" })), + updateDeploymentStatus: vi.fn< + (deploymentId: string, status: string) => Promise + >(async () => ({})), + }; +}); + +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + updateServerById: mocks.updateServerById, +})); +vi.mock("@dokploy/server/services/deployment", () => ({ + createServerDeployment: mocks.createServerDeployment, + updateDeploymentStatus: mocks.updateDeploymentStatus, +})); +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: vi.fn(async () => "http://dokploy.example"), +})); +vi.mock("@dokploy/server/constants", async () => { + const actual = await import("@dokploy/server/constants"); + return { + ...actual, + IS_CLOUD: false, + paths: () => ({ + LOGS_PATH: "/tmp/dokploy-logs", + SSH_PATH: "/etc/dokploy/ssh", + }), + }; +}); +vi.mock("@dokploy/server/utils/filesystem/directory", () => ({ + recreateDirectory: vi.fn(async () => ({})), +})); +vi.mock("@dokploy/server/setup/monitoring-setup", () => ({ + setupMonitoring: vi.fn(async () => ({})), +})); +vi.mock("ssh2", () => ({ Client: mocks.FakeClient })); + +import { serverSetup } from "@dokploy/server/setup/server-setup"; + +describe("serverSetup exit-code propagation", () => { + beforeEach(() => { + mocks.updateDeploymentStatus.mockClear(); + mocks.createServerDeployment.mockClear(); + mocks.updateServerById.mockClear(); + mocks.findServerById.mockClear(); + mocks.configure({ exitCode: 0, stderr: null, stdout: null }); + }); + + it("marks the deployment as 'error' when the remote setup script exits non-zero", async () => { + mocks.configure({ + exitCode: 1, + stderr: "Error: Non-root user requires passwordless sudo access. ❌", + }); + + await serverSetup("srv-1", () => {}); + + // Prove the test exercised the real production script, not a stub: + // defaultCommand(...) always begins with `set -e` and contains the + // passwordless-sudo guard. + const cmd = mocks.getCapturedCommand(); + expect(cmd).toContain("set -e"); + expect(cmd).toContain("sudo -n true"); + + const statuses = mocks.updateDeploymentStatus.mock.calls.map((c) => c[1]); + expect(statuses).toContain("error"); + expect(statuses).not.toContain("done"); + }); + + it("marks the deployment as 'done' and streams the success banner when the remote setup script exits zero", async () => { + mocks.configure({ + exitCode: 0, + stdout: "Setup completed successfully ✅", + }); + + const streamed: string[] = []; + await serverSetup("srv-1", (data) => streamed.push(String(data))); + + const statuses = mocks.updateDeploymentStatus.mock.calls.map((c) => c[1]); + expect(statuses).toContain("done"); + expect(statuses).not.toContain("error"); + + // The success banner is only emitted on the resolve path. + expect(streamed.join("")).toContain("Setup Server: ✅"); + }); + + it("marks the deployment as 'error' when the remote process is killed by a signal (exit code null)", async () => { + // ssh2 emits `null` for the exit code when the remote process was + // terminated by a signal (e.g. OOM). A signal-killed setup leaves the + // server half-installed and must not be reported as success. + mocks.configure({ exitCode: null }); + + await serverSetup("srv-1", () => {}); + + const statuses = mocks.updateDeploymentStatus.mock.calls.map((c) => c[1]); + expect(statuses).toContain("error"); + expect(statuses).not.toContain("done"); + }); +}); diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 7336961d0..edf0ad125 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -311,9 +311,13 @@ const installRequirements = async ( return; } stream - .on("close", () => { + .on("close", (code: number | null) => { client.end(); - resolve(); + if (code === 0) { + resolve(); + } else { + reject(new Error(`Setup script failed with exit code ${code}`)); + } }) .on("data", (data: string) => { onData?.(data.toString()); @@ -417,48 +421,48 @@ export const setupSwarm = () => ` # Try IPv4 with multiple services # First attempt: ifconfig.io - ip=\$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null) + ip=$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null) # Second attempt: icanhazip.com - if [ -z "\$ip" ]; then - ip=\$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null) + if [ -z "$ip" ]; then + ip=$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null) fi # Third attempt: ipecho.net - if [ -z "\$ip" ]; then - ip=\$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) + if [ -z "$ip" ]; then + ip=$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) fi # If no IPv4, try IPv6 with multiple services - if [ -z "\$ip" ]; then + if [ -z "$ip" ]; then # Try IPv6 with ifconfig.io - ip=\$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null) + ip=$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null) # Try IPv6 with icanhazip.com - if [ -z "\$ip" ]; then - ip=\$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null) + if [ -z "$ip" ]; then + ip=$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null) fi # Try IPv6 with ipecho.net - if [ -z "\$ip" ]; then - ip=\$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) + if [ -z "$ip" ]; then + ip=$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) fi fi - if [ -z "\$ip" ]; then + if [ -z "$ip" ]; then echo "Error: Could not determine server IP address automatically (neither IPv4 nor IPv6)." >&2 echo "Please set the ADVERTISE_ADDR environment variable manually." >&2 echo "Example: export ADVERTISE_ADDR=" >&2 exit 1 fi - echo "\$ip" + echo "$ip" } - advertise_addr=\$(get_ip) - echo "Advertise address: \$advertise_addr" + advertise_addr=$(get_ip) + echo "Advertise address: $advertise_addr" # Initialize Docker Swarm - $SUDO_CMD docker swarm init --advertise-addr \$advertise_addr + $SUDO_CMD docker swarm init --advertise-addr $advertise_addr echo "Swarm initialized ✅" fi `; @@ -497,7 +501,7 @@ const installUtilities = () => ` $SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true ;; alpine) - $SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories + $SUDO_CMD sed -i '/^#.*/community/s/^#//' /etc/apk/repositories $SUDO_CMD apk update >/dev/null $SUDO_CMD apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null ;;