fix(server-setup): propagate remote setup exit code to deployment status

This commit is contained in:
detail-app[bot] 2026-09-04 02:51:35 +00:00 committed by GitHub
parent 1572008cdf
commit de012bacfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 198 additions and 19 deletions

View File

@ -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<typeof EventEmitter>;
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<unknown>
>(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");
});
});

View File

@ -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=<your-server-ip>" >&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
;;